class GeoMap { constructor(geojson, container, options) { this.container = container; this.geojson = geojson; this.levels = []; this.defaults = { projectionRotate: [0, 0], projectionParallels: [30, 30], dragZoom: false, clickZoom: false, zoomLevels: {}, title: null, }; this.options = { ...this.defaults, ...options }; this.state = { region: { default: this.geojson, current: null, }, zoomLevel: 1, }; this.layers = {}; this.clearContainer(); if (this.options.title) { this.container.append("h3").text(this.options.title); } this.draw(); } clearContainer() { this.container.html(""); } draw() { this.svg = this.container .selectAll(".map-svg") .data([this.geojson]) .join("svg") .attr("class", "map-svg"); const projection = d3 .geoConicConformal() .parallels(this.options.projectionParallels) .rotate(this.options.projectionRotate) .fitSize( [ this.svg.node().getBoundingClientRect().width, this.svg.node().getBoundingClientRect().height, ], this.geojson ); this.path = d3.geoPath().projection(projection); const backgroundLayer = this.addLayer("background", false); backgroundLayer .on("click", () => this.reset()) .selectAll("rect") .data((d) => [d]) .join("rect") .attr("width", "100%") .attr("height", "100%"); this.zoomLayer = this.svg .selectAll(`.zoom-layer`) .data((d) => [d]) .join("g") .attr("class", `zoom-layer`); this.mapLayer = this.addLayer("map"); this.paths = this.mapLayer .selectAll("path") .data((d) => { if (d.type === "Topology") { const features = topojson.feature( d, d.objects[Object.keys(d.objects)[0]] ).features; return features; } return d.features; }) .join("path") .attr("d", this.path) .on("click", (e, d) => this.regionClick(e, d)); window.addEventListener("resize", () => this.resizeToBounds()); this.zoom = d3 .zoom() .scaleExtent([0.5, 150]) .on("zoom", (e) => { this.calculateResolution(e.transform.k); this.zoomLayer.attr("transform", e.transform); this.mapLayer .selectAll("path") .style("stroke-width", 1 / e.transform.k); this.customZoom(e); }); if (this.options.dragZoom) { this.svg.call(this.zoom); } } get zoomLevels() { Object.defineProperty(this, "zoomLevels", { value: Object.keys(this.options.zoomLevels) .map((d) => +d) .sort((a, b) => b - a), writable: false, configurable: true, }); return this.zoomLevels; } calculateResolution(zoom) { const currentLevel = this.zoomLevels.find((d) => zoom >= d); if (this.state.zoomLevel !== currentLevel) { const currentGeojson = this.options.zoomLevels[currentLevel]; if (currentGeojson) { this.geojson = currentGeojson; this.draw(); this.state.zoomLevel = currentLevel; } } } defaultMergeFunction(features, dataset) { features.forEach((feature) => { const region = dataset.subregions.find((region) => { region.name.toLowerCase() === feature.properties.name.toLowerCase(); }); if (!region) { console.log(`No match found for ${feature.properties.name}`); } else { feature.data = region.data; } }); } mergeData( dataset, mergeFunction = () => this.defaultMergeFunction(this.geojson, dataset) ) { this.dataset = dataset; mergeFunction(this.geojson.features, dataset); return this; } resizeToBounds() { const box = this.svg.node().getBoundingClientRect(); const region = this.state.region.current || this.state.region.default; const bounds = this.path.bounds(region); const width = bounds[1][0] - bounds[0][0]; const height = bounds[1][1] - bounds[0][1]; const scale = Math.min(box.width / width, box.height / height); const transform = d3.zoomIdentity .translate( -bounds[0][0] * scale + (box.width - width * scale) / 2, -bounds[0][1] * scale + (box.height - height * scale) / 2 ) .scale(scale); this.svg .transition() .duration(1000) .ease(d3.easeQuadInOut) .call(this.zoom.transform, transform); } regionClick(e, d) { this.state.region.current = d; this.customClick(e, d); if (this.options.clickZoom) { this.resizeToBounds(this.state.region.current, this.svg); } } customClick(e, d) {} customZoom(e) {} addLayer(name, inZoom = true) { const target = inZoom ? this.zoomLayer : this.svg; const layer = target .selectAll(`.${name}-layer`) .data((d) => [d]) .join("g") .attr("class", `${name}-layer`); this.layers[name] = layer; return layer; } reset() { this.state.region.current = this.state.region.default; this.resizeToBounds(this.state.region.current); } }