src/js/modules/choropleth.js (973 lines of code) (raw):

import { autocomplete, niceNumber, mustache, contains, splitArray } from '../modules/belt' import template from '../../templates/template.html' //import modplate from '../../templates/modal.html' import * as d3 from "d3" import * as topojson from "topojson" // Comment out ractive before deploying import Ractive from 'ractive' //import ractiveFade from 'ractive-transitions-fade' import ractiveTap from 'ractive-events-tap' //import Modal from '../modules/modal' const wait = ms => new Promise(res => setTimeout(res, ms)); export class Choropleth { constructor(data, boundaries, overlay, basemap, places, modal, key, codes, place) { //console.log(overlay, basemap) var self = this this.database = data this.boundaries = boundaries this.overlay = overlay this.basemap = basemap this.places = places this.place = place // Add CSS style for highlighted paths if it doesn't exist if (!document.querySelector('#choropleth-highlight-style')) { const style = document.createElement('style'); style.id = 'choropleth-highlight-style'; style.textContent = ` .mapArea.highlighted { opacity: 0.5; } `; document.head.appendChild(style); } if (this.database.settings[0].filter!="") { let filters = this.database.settings[0].filter.split(",") filters = filters.map(item => item.trim()) if (filters.length == 2) { let features = places.features.filter(item => { return item.properties[filters[0]] == filters[1] }) this.places.features = features } } if (this.database.settings[0].search!="") { if (this.database.settings[0].search.toLowerCase() == 'true') { this.database.displaySearch = true } else { this.database.displaySearch = false } } else { this.database.displaySearch = false } this.database.currentIndex = 0 this.database.locationIndex = 0 this.zoomLevel = 1 this.database.showKey = true // Remove this when we can do a settings.json merge like in superyacht if ("showKey" in self.database.mapping[0]) { if (self.database.mapping[0]['showKey'] != "") { this.database.showKey = self.database.mapping[0]['showKey'] == "TRUE" ? true : false; } } this.database.showLabels = true if ("showLabels" in self.database.mapping[0]) { if (self.database.mapping[0]['showLabels'] != "") { this.database.showLabels = self.database.mapping[0]['showLabels'] == "TRUE" ? true : false; } } console.log("showLabels", this.database.showLabels) //this.toolbelt = new Toolbelt() /* Create a set of keys based on the JSON from the Googledoc data table */ this.database.keys = Object.keys( this.database.data[0] ) // console.log(this.database.keys) this.id = this.database.keys[0] // console.log(this.id) /* Remove the ID column which is going to be used to map items to their corresponding boundaries in the topojson */ this.database.keys = this.database.keys.filter(key => key !== this.id) /* Specify if the graphic requires a dropdown menu based on whether the Google doc contains more than one column (excluding the noew delted ID column) */ this.database.dropdown = (self.database.mapping.map( (item) => item.data).length > 1) ? true : false ; if (self.database.mapping[0].scale === 'swing') { this.database.dropdown = false } this.database.relocate = (self.database.locations.map( (item) => item.data).length > 1) ? true : false ; /* Convert all the datum that looks like a number in the data columns to intergers */ this.database.data.forEach( item => { for (let i = 0; i < self.database.keys.length; i++) { if (!isNaN(item[self.database.keys[i]])) { if (item[self.database.keys[i]] === "") { item[self.database.keys[i]] = null } else if (typeof item[self.database.keys[i]] === 'string' || item[self.database.keys[i]] instanceof String) { item[self.database.keys[i]] = +item[self.database.keys[i]] } // item[self.database.keys[i]] = (item[self.database.keys[i]]!="") ? +item[self.database.keys[i]] : null ; } } }); // this.hasLabels = (self.database.labels.length > 0) ? true : false ; /* Get the name of the topojson object */ console.log("data",this.database.data) this.database.topoKey = Object.keys( this.boundaries.objects )[0] console.log("topokey",this.database.topoKey) this.boundaryID = Object.keys( this.boundaries.objects[this.database.topoKey].geometries[0].properties)[0] //console.log(this.database.settings[0]) if ("boundaryID" in this.database.settings[0]) { if (this.database.settings[0].boundaryID!="") { this.boundaryID = this.database.settings[0].boundaryID } } //console.log(this.boundaryID) if (overlay) { this.overlayTopoKey = Object.keys( this.overlay.objects )[0] this.overlayID = Object.keys( this.overlay.objects[this.overlayTopoKey].geometries[0].properties)[0] } if (basemap) { this.basemapTopoKey = Object.keys( this.basemap.objects )[0] } /* Merge the row data from the Googledoc data table to its corresponding boundary */ this.boundaries.objects[this.database.topoKey].geometries.forEach( item => { item.properties = {...item.properties, ...self.database.data.find((datum) => datum[self.id] === item.properties[self.boundaryID])} }); console.log(boundaries) /* Specify the current key */ this.database.currentKey = self.database.mapping[0].data; // Centre Lat, Lon, Zoom // Defaults to center of Australia, otherwise use set values this.database.centreLat = -28 this.database.centreLon = 135 this.database.zoomScale = 0 try { if (self.database.mapping.length > 0) { let mapArray = Object.keys(self.database.mapping[0]) if (contains(mapArray, 'centreLat') && contains(mapArray, 'centreLon') && contains(mapArray, 'zoomScale')) { this.database.centreLat = +self.database.mapping[0].centreLat; this.database.centreLon = +self.database.mapping[0].centreLon; this.database.zoomScale = +self.database.mapping[0].zoomScale; } } if (self.database.locations.length > 0) { let locArray = Object.keys(self.database.locations[0]) if (contains(locArray, 'centreLat') && contains(locArray, 'centreLon') && contains(locArray, 'zoomScale')) { this.database.centreLat = +self.database.locations[0].centreLat; this.database.centreLon = +self.database.locations[0].centreLon; this.database.zoomScale = +self.database.locations[0].zoomScale; } } } catch (error) { console.log("Add location lat and lng") // Expected output: ReferenceError: nonExistentFunction is not defined // (Note: the exact output may be browser-dependent) } // rename this to zoomLevel later /* Check to see if user is on a mobile. If the user is on a mobile lock the map by default */ this.database.zoomOn = true var ua = navigator.userAgent.toLowerCase(); var isAndroid = ua.indexOf("android") > -1; var isAndroidApp = (window.location.origin === "file://" && isAndroid || window.location.origin === null && isAndroid || window.location.origin === "https://mobile.guardianapis.com" && isAndroid ) ? true : false ; this.database.isAndroidApp = (isAndroid && !modal) ? true : false ; this.database.searchBlock = "" this.database.autocompleteArray = [] this.database.key = key this.database.codes = codes this.database.displayOverlay = false this.database.version = "" //`${window.location.origin}` this.ractivate() } ractivate() { var self = this Ractive.DEBUG = false; this.ractive = new Ractive({ events: { tap: ractiveTap }, el: '#choloropleth', data: self.database, template: template, }) this.colourizer() this.createMap() if (!this.database.isAndroidApp ) { this.resizer() this.ractive.observe('currentIndex', function(index) { self.database.currentIndex = index self.database.currentKey = self.database.mapping[index].data self.colourizer() self.updateMap() self.keygen() }); this.ractive.observe('locationIndex', function(index) { self.database.locationIndex = index self.reposMap() }); } //this.ractive.on( 'view', ( context ) => self.showMap()); if (!this.database.isAndroidApp ) { this.ractive.observe( 'searchBlock', ( newValue ) => { if (newValue && newValue.length > 2) { self.database.displayOverlay = true self.database.autocompleteArray = autocomplete(newValue, self.database.codes, 'meta') } else { self.database.displayOverlay = false self.database.autocompleteArray = [] } self.ractive.set(self.database) }); this.ractive.on( 'keydown', function ( event ) { if (event.original.keyCode===13) { if (self.database.autocompleteArray.length > 0) { self.database.autocompleteArray = [] self.database.searchBlock = "" self.ractive.set(self.database) self.ractive.set('displayOverlay', true) self.relocate(self.database.autocompleteArray[0]) //setTimeout(self.displayMap(), 2000); } event.original.preventDefault() } }); this.ractive.on('search', (context, data) => { self.database.autocompleteArray = [] self.database.searchBlock = "" self.ractive.set(self.database) self.ractive.set('displayOverlay', true) self.relocate(data) //setTimeout(self.displayMap(), 2000); }) } } displayMap() { var self = this console.log("Display map") self.database.displayOverlay = false self.database.autocompleteArray = [] self.database.searchBlock = "" self.ractive.set(self.database) } async relocate(arr) { // https://stackoverflow.com/questions/14492284/center-a-map-in-d3-given-a-geojson-object // https://observablehq.com/@d3/zoom-to-bounding-box // https://www.d3indepth.com/geographic/ //this.database.zoomOn = false var self = this d3.select("#suburb").remove() let bbox = await getJson(`https://interactive.guim.co.uk/embed/aus/2023/01/australian-air-quality/geojson/${arr.postcode}.geojson`) if (bbox) { let geojson = { "type": "FeatureCollection", "features": [ bbox ] } let [[x0, y0], [x1, y1]] = this.path.bounds(geojson) var svg = d3.select("#mapContainer svg") svg.transition().duration(750).call( self.zoom.transform, d3.zoomIdentity .translate(self.width / 2, self.height / 2) .scale(.7 / Math.max((x1 - x0) / self.width, (y1 - y0) / self.height)) .translate(-(x0 + x1) / 2, -(y0 + y1) / 2) ); svg.append("g") .attr("id","suburb") .selectAll("path") .data(geojson.features) .join("path") .attr("d",self.path) .attr("class", "burbs") .attr("stroke-width", 1) .attr("stroke", "black") .attr("stroke-dasharray", "5,5") .attr("fill", "none") await wait(1500); self.ractive.set('displayOverlay', false) } else { console.log("Missing postcode") self.ractive.set('displayOverlay', false) } } resizer() { var self = this var to = null var lastWidth = document.querySelector(".interactive-container").getBoundingClientRect() window.addEventListener('resize', () => { var thisWidth = document.querySelector(".interactive-container").getBoundingClientRect() if (lastWidth != thisWidth) { window.clearTimeout(to); to = window.setTimeout(function() { self.zoomLevel = 1 self.createMap() self.updateMap() }, 500) } }) } colourizer() { var self = this this.scaleType = self.database.mapping[self.database.currentIndex].scale.toLowerCase() this.database.election = (this.scaleType === "election") ? true : false ; this.database.swing = (this.scaleType === "swing") ? true : false ; this.database.key = (this.scaleType != "election" && this.scaleType != "swing") ? true : false ; this.keyColors = splitArray(self.database.mapping[self.database.currentIndex].colours); this.thresholds = self.database.mapping[self.database.currentIndex].values.split(","); //self.database.key.map( (item) => item.value); this.min = d3.min( self.database.data, (item) => item[self.database.currentKey]); this.max = d3.max( self.database.data, (item) => item[self.database.currentKey]); if (this.thresholds) { if (this.thresholds.length == 2) { this.min = this.thresholds[0] this.max = this.thresholds[1] } } this.median = d3.median( self.database.data, (item) => item[self.database.currentKey]); this.mean = d3.mean( self.database.data, (item) => item[self.database.currentKey]); this.range = self.database.data.map( (item) => item[self.database.currentKey]); if (this.scaleType === "threshold") { let thresholds2 = this.thresholds.slice(1, -1); // Remove first and last elements console.log("thresholds2", thresholds2) console.log("keyColors", self.keyColors) this.domain = thresholds2; this.color = d3.scaleThreshold().domain(thresholds2).range(self.keyColors) } else if (this.scaleType === "election") { var marginQuint = [6, 12, 18, 24]; var colBlue = ['rgb(189,215,231)','rgb(107,174,214)','rgb(49,130,189)','rgb(8,81,156)']; var colRed = ['rgb(252,174,145)','rgb(251,106,74)','rgb(222,45,38)','rgb(165,15,21)']; var colPurple = ['rgb(203,201,226)','rgb(158,154,200)','rgb(117,107,177)','rgb(84,39,143)']; var scaleBlue = d3.scaleThreshold() .domain(marginQuint) .range(colBlue); var scaleRed = d3.scaleThreshold() .domain(marginQuint) .range(colRed); var scalePurple = d3.scaleThreshold() .domain(marginQuint) .range(colPurple); this.color = function(margin,party) { if (party === "NAT" | party === "LIB" | party === "LNP") { return scaleBlue(margin) } else if (party === "ALP") { return scaleRed(margin) } else { return scalePurple(margin) } } } //https://interactive.guim.co.uk/embed/iframeable/2019/03/choropleth_map_maker-swingtest-v2/html/index.html else if (this.scaleType === "swing") { this.colBlue = ['rgb(189,215,231)','rgb(8,81,156)']; this.colRed = ['rgb(252,174,145)','rgb(165,15,21)']; this.scaleBlue = d3.scaleLinear().domain([0, 10]).range(self.colBlue); this.scaleRed = d3.scaleLinear().domain([0, 10]).range(self.colRed); this.color = function(swing, prediction) { return (swing < 0) ? self.scaleRed(Math.abs(swing)) : self.scaleBlue(swing) ; } } else if (this.scaleType === "ordinal") { console.log(self.thresholds, self.keyColors) // this.domain = [self.min, self.max] this.color = d3.scaleOrdinal().domain(self.thresholds).range(self.keyColors) } else if (this.scaleType === "linear median") { // Median this.domain = [self.min, self.median, self.max] this.color = d3.scaleLinear().domain([self.min, self.median, self.max]).range(self.keyColors); } else if (this.scaleType === "linear mean") { // Mean this.domain = [self.min, self.mean, self.max] this.color = d3.scaleLinear().domain([self.min, self.mean, self.max]).range(self.keyColors); } else if (this.scaleType === "quantile") { this.domain = [self.min, self.max] this.color = d3.scaleQuantile().domain(this.domain).range(self.keyColors); } else if (this.scaleType === "quantize") { this.domain = [self.min, self.max] this.color = d3.scaleQuantize().domain(self.min, self.max).range(self.keyColors); } else { // Linear by default this.scaleType = "linear" this.domain = [self.min, self.max] this.color = d3.scaleLinear().domain([self.min, self.max]).range(self.keyColors); } var output = `Scale type: ${this.scaleType}\nColours: ${self.keyColors}\nThresholds: ${self.thresholds}\nMin: ${this.min}\nMax: ${this.max}\nMedian: ${this.median}\nMean: ${this.mean}\n\n------------------` } getFill(d) { var self = this if (self.scaleType != "election" && self.scaleType != "swing") { if (self.scaleType == "threshold" ) { if (d.properties[self.database.currentKey] === 0 | d.properties[self.database.currentKey] === "0") { return "#FFFFFF"; } else if (d.properties[self.database.currentKey]==null) { return '#dcdcdc'; } else if (typeof d.properties[self.database.currentKey] == 'string') { return "url(#crosshatch)" } else { return self.color(d.properties[self.database.currentKey]) } } if (self.scaleType == "ordinal" ) { if (d.properties[self.database.currentKey]==null) { return '#dcdcdc'; } else { return self.color(d.properties[self.database.currentKey]) } } else { if (d.properties[self.database.currentKey]==null) { return '#dcdcdc'; } else if (typeof d.properties[self.database.currentKey] == 'string') { return "url(#crosshatch)" } else { return self.color(d.properties[self.database.currentKey]) } } } // Special electoral maps else if (self.scaleType === "election" ) { return (d.properties.Margin!=null) ? self.color(d.properties.Margin, d.properties['Notional incumbent']) : '#dcdcdc' ; } else if (self.scaleType === "swing" ) { return (d.properties["2PPSwing"]!=null) ? self.color(d.properties["2PPSwing"], d.properties['Prediction']) : '#dcdcdc' ; } } keygen() { var self = this var keyLeftMargin = 10 var keyRightMargin = 20 // this.scaleType = self.database.mapping[self.database.currentIndex].scale.toLowerCase() var keyText = null if (this.database.mapping[self.database.currentIndex]['keyText']) { if (this.database.mapping[self.database.currentIndex]['keyText'] != "") { keyText = this.database.mapping[self.database.currentIndex]['keyText'] } } if (keyText != null) { d3.select("#keyText").html(keyText) } this.svgWidth = 300 if (this.svgWidth > this.width - 10) { this.svgWidth = this.width - 10 } this.keyWidth = this.svgWidth - keyRightMargin - keyLeftMargin d3.select("#keyContainer").html(""); d3.select("#keyContainer svg").remove(); d3.select("#keyContainer1 svg").remove(); d3.select("#keyContainer2 svg").remove(); d3.select("#keyContainer3 svg").remove(); this.keySquare = this.keyWidth / 10; const barHeight = 15 const height = 30 if (this.scaleType === "swing") { var value = [0, 2, 4, 6, 8, 10]; var label = [0, 2, 4, 6, 8, "10+"]; this.keyWidth = document.querySelector("#keyContainer1").getBoundingClientRect().width - 10 this.keySquare = this.keyWidth / 6; this.keySvg1 = d3.select("#keyContainer1").append("svg") .attr("width", self.svgWidth) .attr("height", "40px") .attr("id", "keySvg1") this.keySvg2 = d3.select("#keyContainer2").append("svg") .attr("width", self.svgWidth) .attr("height", "40px") .attr("id", "keySvg2") value.forEach(function(d, i) { self.keySvg1.append("rect") .attr("x", self.keySquare * i) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", self.scaleBlue(d)) .attr("stroke", "#dcdcdc") }) label.forEach(function(d, i) { self.keySvg1.append("text") .attr("x", (i) * self.keySquare) .attr("text-anchor", "start") .attr("y", height) .attr("class", "keyLabel").text(d) }) value.forEach(function(d, i) { self.keySvg2.append("rect") .attr("x", self.keySquare * i) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", self.scaleRed(d)) .attr("stroke", "#dcdcdc") }) label.forEach(function(d, i) { self.keySvg2.append("text") .attr("x", (i) * self.keySquare) .attr("text-anchor", "start") .attr("y", height) .attr("class", "keyLabel").text(d) }) } if (this.scaleType === "threshold") { this.keySvg = d3.select("#keyContainer").append("svg") .attr("width", self.svgWidth) .attr("height", "40px") .attr("id", "keySvg") this.keyColors.forEach(function(d, i) { self.keySvg.append("rect") .attr("x", (self.keySquare * i) + keyLeftMargin) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", d) .attr("stroke", "#dcdcdc") }) var threshLen = this.thresholds.length this.thresholds.forEach(function(d, i) { // I keep changing this between removing the last figure and not removing it and I can't remember why self.keySvg.append("text") .attr("x", (i) * self.keySquare + keyLeftMargin) .attr("text-anchor", "middle") .attr("y", height) .attr("class", "keyLabel").text(niceNumber(d)) }) } if (this.scaleType === "linear") { this.keySvg = d3.select("#keyContainer").append("svg") .attr("width", self.svgWidth) .attr("height", "40px") .attr("id", "keySvg") var legend = this.keySvg.append("defs") .append("svg:linearGradient") .attr("id", "gradient") .attr("x1", "0%") .attr("y1", "100%") .attr("x2", "100%") .attr("y2", "100%") .attr("spreadMethod", "pad"); legend.append("stop") .attr("offset", "0%") .attr("stop-color", this.keyColors[0]) .attr("stop-opacity", 1); legend.append("stop") .attr("offset", "100%") .attr("stop-color", this.keyColors[1]) .attr("stop-opacity", 1); this.keySvg.append("rect") .attr("y", 0) .attr("x", keyLeftMargin) .attr("width", self.keyWidth) .attr("height", barHeight) .style("fill", "url(#gradient)") self.keySvg.append("text") .attr("x", 10) .attr("y", height) .attr("class", "keyLabel").text(niceNumber(this.min)) self.keySvg.append("text") .attr("x", self.keyWidth + keyLeftMargin) .attr("text-anchor", "end") .attr("y", height) .attr("class", "keyLabel").text(niceNumber(this.max)) } if (this.scaleType === "ordinal") { var html = ''; this.thresholds.forEach(function(d, i) { html += '<div class="keyDiv"><span class="keyCircle" style="background: ' + self.keyColors[i] + '"></span>'; html += ' <span class="keyText">' + d + '</span></div>'; }) d3.select('#keyContainer').html(html); } if (this.scaleType === "quantile") { this.keySvg = d3.select("#keyContainer").append("svg") .attr("width", self.keyWidth) .attr("height", "40px") .attr("id", "keySvg") this.keyColors.forEach(function(d, i) { self.keySvg.append("rect") .attr("x", (self.keySquare * i) + keyLeftMargin) .attr("y", 0) .attr("width", self.keySquare) .attr("height", 15) .attr("fill", d) .attr("stroke", "#dcdcdc") }) self.keySvg.append("text") .attr("x", 0) .attr("text-anchor", "start") .attr("y", 30) .attr("class", "keyLabel").text(this.min) self.keySvg.append("text") .attr("x", this.keyWidth) .attr("text-anchor", "end") .attr("y", 30) .attr("class", "keyLabel").text(niceNumber(this.max)) } if (this.scaleType === "election") { var marginQuint = [0, 6, 12, "18+"]; var colBlue = ['rgb(189,215,231)','rgb(107,174,214)','rgb(49,130,189)','rgb(8,81,156)']; var colRed = ['rgb(252,174,145)','rgb(251,106,74)','rgb(222,45,38)','rgb(165,15,21)']; var colPurple = ['rgb(203,201,226)','rgb(158,154,200)','rgb(117,107,177)','rgb(84,39,143)']; this.keyWidth = document.querySelector("#keyContainer1").getBoundingClientRect().width - 10 this.keySquare = this.keyWidth / 6; this.keySvg1 = d3.select("#keyContainer1").append("svg") .attr("width", self.keyWidth) .attr("height", "40px") .attr("id", "keySvg1") this.keySvg2 = d3.select("#keyContainer2").append("svg") .attr("width", self.keyWidth) .attr("height", "40px") .attr("id", "keySvg2") this.keySvg3 = d3.select("#keyContainer3").append("svg") .attr("width", self.keyWidth) .attr("height", "40px") .attr("id", "keySvg3") colBlue.forEach(function(d, i) { self.keySvg1.append("rect") .attr("x", self.keySquare * i) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", d) .attr("stroke", "#dcdcdc") }) marginQuint.forEach(function(d, i) { self.keySvg1.append("text") .attr("x", (i) * self.keySquare) .attr("text-anchor", "start") .attr("y", height) .attr("class", "keyLabel").text(niceNumber(d)) }) colRed.forEach(function(d, i) { self.keySvg2.append("rect") .attr("x", self.keySquare * i) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", d) .attr("stroke", "#dcdcdc") }) marginQuint.forEach(function(d, i) { self.keySvg2.append("text") .attr("x", (i) * self.keySquare) .attr("text-anchor", "start") .attr("y", height) .attr("class", "keyLabel").text(niceNumber(d)) }) colPurple.forEach(function(d, i) { self.keySvg3.append("rect") .attr("x", self.keySquare * i) .attr("y", 0) .attr("width", self.keySquare) .attr("height", barHeight) .attr("fill", d) .attr("stroke", "#dcdcdc") }) marginQuint.forEach(function(d, i) { self.keySvg3.append("text") .attr("x", (i) * self.keySquare) .attr("text-anchor", "start") .attr("y", height) .attr("class", "keyLabel").text(niceNumber(d)) }) } } placeNames() { var self = this var placeLabelThreshold = 1 if (self.width <= 620) { placeLabelThreshold = 1 } d3.selectAll(`.labels`) .style("display", (d) => { return (d.properties.scalerank - placeLabelThreshold < self.zoomLevel -1) ? "block" : "none" }) .style("font-size", (d) => { return 11 / self.zoomLevel + "px"}) .attr("x", (d) => self.projection([d.properties.longitude, d.properties.latitude])[0]) .attr("y", (d) => self.projection([d.properties.longitude, d.properties.latitude])[1]) } createMap() { var self = this this.width = document.querySelector("#mapContainer").getBoundingClientRect().width this.height = (this.width < 500) ? this.width * 0.8 : this.width * 0.6 ; var margin = { top: 0, right: 0, bottom: 0, left: 0 } var active = d3.select(null); var scaleFactor = 0.85; if (self.place == "world") { scaleFactor = 0.17 } self.projection = d3.geoMercator() .center([self.database.centreLon, self.database.centreLat]) .scale(self.width * scaleFactor) .translate([self.width / 2, self.height / 2]) var path = d3.geoPath().projection(self.projection); this.path = path var graticule = d3.geoGraticule(); const maxZoom = 300 this.zoom = d3.zoom().scaleExtent([1, maxZoom]).on("zoom", zoomed); d3.select("#mapContainer svg").remove(); var svg = d3.select("#mapContainer").append("svg") .attr("width", self.width) .attr("height", self.height) .attr("id", "map") //.attr("overflow", "hidden") .on("mousemove", function() { const event = d3.event || window.event; updateTooltipPosition(event); }); if (self.database.zoomOn) { console.log("Zoom") svg.call(self.zoom) } svg.append("svg:defs").append("svg:marker") .attr("id", "triangle") .attr("refX", 6) .attr("refY", 6) .attr("markerWidth", 30) .attr("markerHeight", 30) .attr("markerUnits","userSpaceOnUse") .attr("orient", "auto") .append("path") .attr("d", "M 0 0 12 6 0 12 3 6") .style("fill", "black"); var crosshatch = svg.append("defs") .append("pattern") .attr("id", "crosshatch") .attr("patternUnits", "userSpaceOnUse") .attr("width", 10) .attr("height", 10); // Append lines to the pattern for crosshatching crosshatch.append("path") .attr("d", "M-1,1 l2,-2 M0,10 l10,-10 M9,11 l2,-2") .attr("stroke", "black") .attr("stroke-width", 1) .attr("shape-rendering", "auto"); var tooltip = d3.select("#mapContainer").append("div") .attr("class", "tooltip") .style("position", "absolute") .style("z-index", "20") .style("visibility", "hidden") .style("top", "30px") .style("left", "55px") .html("<div id='overlay'></div><div id='tooltip'></div>") var features = svg.append("g") features.append("path").datum(graticule) .attr("class", "graticule") .attr("d", path); // console.log("topoKey",self.database.topoKey) // features.append("g").selectAll("path").data(topojson.feature(self.boundaries, self.boundaries.objects[self.database.topoKey]).features).enter().append("path") // .attr("class", `_${self.database.topoKey} mapArea`) var geoLayers = features.append("g") .attr("id", "geoLayers") if (this.basemap) { geoLayers.append("g").selectAll("path") .data(topojson.feature(self.basemap, self.basemap.objects[this.basemapTopoKey]).features).enter().append("path") .attr("class", "basemap") .style("fill",'#dcdcdc') .attr("d",path) .attr("stroke-width", 1) .attr("stroke", null) } geoLayers.append("g").selectAll("path").data(topojson.feature(self.boundaries, self.boundaries.objects[this.database.topoKey]).features).enter().append("path") .attr("class", self.database.topoKey + " mapArea") .attr("fill", function(d) { return self.getFill(d) }) .attr("d",path) .on("mouseover", tooltipIn) .on("mousemove", function(d) { const event = d3.event || window.event; updateTooltipPosition(event); }) .on("mouseout", tooltipOut); if (self.width > 480) { geoLayers.append("path") .attr("class", "mesh") .attr("stroke-width", 0.5) .attr("d", path(topojson.mesh(self.boundaries, self.boundaries.objects[this.database.topoKey]))); } if (this.overlay) { geoLayers.append("g").selectAll("path") .data(topojson.feature(self.overlay, self.overlay.objects[this.overlayTopoKey]).features) .enter() .append("path") .attr("class", "overlay") .style("fill", "transparent") .attr("d", path) .attr("stroke-width", 1) .attr("stroke", "#000") .style("pointer-events", "all") .on("mouseover", passThru) .on("mousemove", passThru) .on("mouseout", tooltipOut); } // geoLayers // .on("mouseover", tooltipIn) // .on("mouseout", tooltipOut) function passThru(d, i, nodes) { // Get the current event const event = d3.event || window.event; // Store the overlay data var overlayData = d; // Temporarily disable pointer events on this element var prev = this.style.pointerEvents; this.style.pointerEvents = 'none'; // Get the element below var element = document.elementFromPoint(event.clientX, event.clientY); // Restore pointer events immediately to prevent mouse event issues this.style.pointerEvents = prev; // If we found an element and it's a map area if (element && element.classList.contains('mapArea')) { // Remove highlight from all paths d3.selectAll('.mapArea').classed('highlighted', false); // Add highlight to current path d3.select(element).classed('highlighted', true); // Get the data from the underlying element var baseData = d3.select(element).datum(); // Show combined tooltip d3.select(".tooltip") .style("visibility", "visible") .select("#tooltip") .html(() => { // Combine data from both layers let baseHtml = baseData.properties[self.database.currentKey] !== null ? mustache(self.database.mapping[self.database.currentIndex].tooltip, {...utilities, ...baseData.properties}) : "No data available"; let overlayHtml = overlayData.properties ? mustache(self.database.mapping[self.database.currentIndex].overlayTooltip, {...utilities, ...overlayData.properties}) : ""; return `${overlayHtml}<hr style="margin: 5px 0;">${baseHtml}`; }); // Update tooltip position updateTooltipPosition(event); } else { // If we're not over a map area, remove all highlights d3.selectAll('.mapArea').classed('highlighted', false); } // Prevent event from continuing event.stopPropagation(); } function updateTooltipPosition(event) { if (!event) return; const containerRect = d3.select("#mapContainer").node().getBoundingClientRect(); const relativeX = event.clientX - containerRect.left; const relativeY = event.clientY - containerRect.top; const half = self.width / 2; if (relativeX < half) { d3.select(".tooltip").style("left", relativeX + "px"); } else { d3.select(".tooltip").style("left", (relativeX - 200) + "px"); } if (relativeY < (self.height / 2)) { d3.select(".tooltip").style("top", (relativeY + 30) + "px"); } else { d3.select(".tooltip").style("top", (relativeY - 120) + "px"); } } function tooltipOut(d) { const event = d3.event || window.event; // Add a small delay to prevent flickering when moving between features setTimeout(() => { // Check if we're still outside both layers var elementAtPoint = document.elementFromPoint(event.clientX, event.clientY); if (!elementAtPoint?.classList.contains('mapArea') && !elementAtPoint?.classList.contains('overlay')) { d3.select(".tooltip").style("visibility", "hidden"); // Remove highlight from all paths when truly leaving the map area d3.selectAll('.mapArea').classed('highlighted', false); } }, 100); } function tooltipIn(d) { d3.select(".tooltip").style("visibility", "visible"); // Remove highlight from all paths d3.selectAll('.mapArea').classed('highlighted', false); // Add highlight to current path d3.select(this).classed('highlighted', true); if (d.properties[self.database.currentKey] === 0) { d.properties[self.database.currentKey] = "0"; d3.select("#tooltip").html(mustache(self.database.mapping[self.database.currentIndex].tooltip, {...utilities, ...d.properties})); } else if (d.properties[self.database.currentKey] === undefined) { d3.select("#tooltip").html("No data available"); } else { d3.select("#tooltip").html((d.properties[self.database.currentKey] === null) ? "No data available" : mustache(self.database.mapping[self.database.currentIndex].tooltip, {...utilities, ...d.properties})); } } var placeLabelThreshold = 1 if (self.width <= 620) { placeLabelThreshold = 1 } if (self.database.showLabels) { features.selectAll("text") .data(self.places.features) .enter() .append("text") .text((d) => d.properties.name) .attr("x", (d) => self.projection([d.properties.longitude, d.properties.latitude])[0]) .attr("y", (d) => self.projection([d.properties.longitude, d.properties.latitude])[1]) .attr("class","labels") .style("display", (d) => { return (d.properties.scalerank - placeLabelThreshold < self.zoomLevel - 1) ? "block" : "none" }) } svg.on("click", function() { console.log(self.projection.invert(d3.mouse(this)), self.zoomLevel) }) this.keygen() //145.4866862 -22.2187986 145.4866862 -22.2187986 /* if (self.hasLabels) { for (var i = 0; i < self.database.labels.length; i++) { console.log("Added one label") features.append("line") .attr("x1", self.projection([+self.database.labels[i].lon_end, +self.database.labels[i].lat_end])[0]) .attr("y1", self.projection([+self.database.labels[i].lon_end, +self.database.labels[i].lat_end])[1]) .attr("x2", self.projection([+self.database.labels[i].lon_start, +self.database.labels[i].lat_start])[0]) .attr("y2", self.projection([+self.database.labels[i].lon_start, +self.database.labels[i].lat_start])[1]) .attr("stroke-width", 1) .attr("stroke", "black") .attr("marker-end", "url(#triangle)") features.append("text") .text((d) => self.database.labels[i].text) .attr("x", self.projection([+self.database.labels[i].lon_end, +self.database.labels[i].lat_end])[0] + 10) .attr("y", self.projection([+self.database.labels[i].lon_end, +self.database.labels[i].lat_end])[1] + 10) .attr("class","labels") } }*/ var utilities = { commas: function(num) { var result = parseFloat(this[num]).toFixed(); result = result.replace(/(\d)(?=(\d{3})+$)/g, '$1,'); return result }, big: function(big) { var num = parseFloat(this[big]); if ( num > 0 ) { if ( num > 1000000000 ) { return ( num / 1000000000 ).toFixed(1) + 'bn' } if ( num > 1000000 ) { return ( num / 1000000 ).toFixed(1) + 'm' } if (num % 1 != 0) { return num.toFixed(2) } else { return num.toLocaleString() } } if ( num < 0 ) { var posNum = num * -1; if ( posNum > 1000000000 ) return [ "-" + String(( posNum / 1000000000 ).toFixed(1)) + 'bn']; if ( posNum > 1000000 ) return ["-" + String(( posNum / 1000000 ).toFixed(1)) + 'm']; else { return num.toLocaleString() } } return num; }, decimals: function(items) { var nums = items.split(",") return parseFloat(this[nums[0]]).toFixed(nums[1]); } } d3.select("#zoomIn").on("click", function(d) { self.zoom.scaleBy(svg.transition().duration(750), 1.5); }); d3.select("#zoomOut").on("click", function(d) { self.zoom.scaleBy(svg.transition().duration(750), 1 / 1.5); }); /* d3.select("#zoomToggle").on("click", function(d) { toggleZoom(); }); */ self.zoom.scaleBy(svg, self.database.zoomScale); /* function toggleZoom() { if (self.database.zoomOn == false) { d3.select("#zoomToggle").classed("zoomLocked", false) d3.select("#zoomToggle").classed("zoomUnlocked", true) svg.call(self.zoom); self.database.zoomOn = true } else if (self.database.zoomOn == true) { svg.on('.zoom', null); d3.select("#zoomToggle").classed("zoomLocked", true) d3.select("#zoomToggle").classed("zoomUnlocked", false) self.database.zoomOn = false } else if (self.database.zoomOn == null) { svg.on('.zoom', null); d3.select("#zoomToggle").classed("zoomLocked", true) d3.select("#zoomToggle").classed("zoomUnlocked", false) svg.call(self.zoom); self.database.zoomOn = false } } */ function zoomed(event) { console.log("Zoom it") scaleFactor = d3.event.transform.k; d3.selectAll(".mesh").style("stroke-width", 0.5 / d3.event.transform.k + "px"); d3.selectAll(".burbs").style("stroke-width", 0.5 / d3.event.transform.k + "px").attr("stroke-dasharray", `${2 / d3.event.transform.k }, ${2 / d3.event.transform.k }`) d3.selectAll("#suburb").attr("transform", d3.event.transform); features.style("stroke-width", 0.5 / d3.event.transform.k + "px"); features.selectAll(".overlay").attr("stroke-width", 1 / d3.event.transform.k + "px"); features.attr("transform", d3.event.transform); features.selectAll(".placeContainers").style("display", function(d) { return (d['properties']['scalerank'] - 3 < d3.event.transform.k) ? "block" : "none" ; }) d3.select('#crosshatch') .attr('patternTransform', 'scale(' + 1 / d3.event.transform.k + ')'); features.selectAll(".placeText") .style("font-size", 0.8 / d3.event.transform.k + "rem") .attr("dx", 5 / d3.event.transform.k) .attr("dy", 5 / d3.event.transform.k); clearTimeout(document.body.data) var now = d3.event.transform.k; //console.log(self.projection.invert([d3.event.transform.y, d3.event.transform.x])) document.body.data = setTimeout( function() { if (now!=self.zoomLevel) { self.zoomLevel = now self.placeNames() } }, 200); } function reset() { active.classed("active", false); active = d3.select(null); svg.transition().duration(750).call(self.zoom.transform, d3.zoomIdentity); } } updateMap() { console.log("update map") var self = this d3.selectAll(`.${self.database.topoKey}`).transition("changeFill") .attr("fill", (d) => self.getFill(d)) } reposMap() { var self = this console.log("Repo") var newCentreLat = +self.database.locations[self.database.locationIndex].centreLat var newCentreLon = +self.database.locations[self.database.locationIndex].centreLon var point = self.projection([newCentreLon, newCentreLat]) var zoomScale = +self.database.locations[self.database.locationIndex].zoomScale if (newCentreLon) { console.log(newCentreLat, newCentreLon, zoomScale, self.projection(newCentreLon)) d3.select("#mapContainer svg").transition().duration(750).call( self.zoom.transform, d3.zoomIdentity.translate(self.width / 2, self.height / 2).scale(zoomScale).translate(-point[0], -point[1]) ); } } /* // Old scool before we split into two dropdown paths... updateMap() { var self = this d3.selectAll(`.${self.database.topoKey}`).transition("changeFill") .attr("fill", (d) => { return (d.properties[self.database.currentKey]!=null) ? self.color(d.properties[self.database.currentKey]) : 'lightgrey' }) var newCentreLat = +self.database.mapping[self.database.currentIndex].centreLat var newCentreLon = +self.database.mapping[self.database.currentIndex].centreLon var point = self.projection([newCentreLon, newCentreLat]) var zoomScale = +self.database.mapping[self.database.currentIndex].zoomScale if (newCentreLon) { console.log(newCentreLat, newCentreLon, zoomScale, self.projection(newCentreLon)) d3.select("#mapContainer svg").transition().duration(750).call( self.zoom.transform, d3.zoomIdentity.translate(self.width / 2, self.height / 2).scale(zoomScale).translate(-point[0], -point[1]) ); } } */ } async function getJson(url) { return fetch(`${url}`).then(r => r.json().catch( () => false)) }