uzb-d3-map
Version:
A flexible, customizable SVG map component for Uzbekistan built with D3 and React
319 lines • 11.7 kB
JavaScript
import React from "react";
import { createRoot } from "react-dom/client";
import { LabelRenderer } from "../components/label-renderer";
import { DEFAULT_ANGLE, DEFAULT_H, DEFAULT_TURN, DEFAULT_V } from "../constants";
import { labelConfig as defaultLabelConfig } from "../data";
import { borderPointForSide, clampToView, distanceToRectEdgeAlongDir, rotateUnit, unitFromAngle, unitFromSide, } from "./geometry";
import { measureLines } from "./text";
const TRANSITION_DURATION = 100;
const STANDARD_ATTRS = new Set(["fill", "stroke", "strokeWidth"]);
/**
* Creates a style resolver function that merges custom styles with defaults
*/
const createStyleResolver = (config) => {
const { colors, stroke, activeKey, getPathStyle } = config;
return (data) => {
const isActive = data.key === activeKey;
const defaultFill = isActive ? colors.active : colors.default;
const customStyle = getPathStyle?.(data);
const fill = customStyle?.fill ?? defaultFill;
return {
...customStyle,
fill,
stroke: customStyle?.stroke ?? stroke.color,
strokeWidth: customStyle?.strokeWidth ?? stroke.width,
};
};
};
/**
* Applies style attributes to a D3 selection
*/
const applyStyleToSelection = (selection, style, useTransition = false) => {
const fillValue = style.fill;
if (fillValue == null)
return;
if (useTransition) {
const trans = selection.transition().duration(TRANSITION_DURATION);
trans.attr("fill", fillValue);
if (style.stroke != null)
trans.attr("stroke", style.stroke);
if (style.strokeWidth != null)
trans.attr("stroke-width", style.strokeWidth);
for (const [key, value] of Object.entries(style)) {
if (!STANDARD_ATTRS.has(key) && value != null) {
trans.attr(key, value);
}
}
}
else {
selection.attr("fill", fillValue);
if (style.stroke != null)
selection.attr("stroke", style.stroke);
if (style.strokeWidth != null)
selection.attr("stroke-width", style.strokeWidth);
for (const [key, value] of Object.entries(style)) {
if (!STANDARD_ATTRS.has(key) && value != null) {
selection.attr(key, value);
}
}
}
};
/**
* Renders region paths with appropriate styling and event handlers
*/
function renderRegions(d3, regionLayer, data, config, onClick) {
const resolveStyle = createStyleResolver(config);
const { activeKey } = config;
const regionSel = regionLayer
.selectAll("path.region")
.data(data, (d) => d.key)
.join("path")
.attr("class", (d) => d.key === activeKey ? "map-region region map-region-active" : "map-region region")
.attr("d", (d) => d.d)
.attr("cursor", "pointer")
.each(function (d) {
applyStyleToSelection(d3.select(this), resolveStyle(d));
})
.on("click", (_e, d) => onClick(d.key));
return regionSel;
}
/**
* Calculates label position based on configuration
*/
function calculateLabelPosition(cfg, elbow, ux2, uy2, hw, hh, vLen) {
const gap = 12;
const posX = elbow.x + vLen * ux2;
const posY = elbow.y + vLen * uy2;
if (cfg.side) {
switch (cfg.side) {
case "right":
return { x: posX + (hw + gap), y: posY };
case "left":
return { x: posX - (hw + gap), y: posY };
case "top":
return { x: posX, y: posY - (hh + gap) };
case "bottom":
return { x: posX, y: posY + (hh + gap) };
}
}
const edgeDist = distanceToRectEdgeAlongDir(hw, hh, ux2, uy2);
return {
x: elbow.x + (vLen + edgeDist + gap) * ux2,
y: elbow.y + (vLen + edgeDist + gap) * uy2,
};
}
/**
* Calculates label nodes positions based on region centroids and configuration
*/
function calculateLabelNodes(svgSel, regionSel, labelConfigMap) {
const centroids = [];
regionSel.each(function (d) {
const bbox = this.getBBox();
centroids.push({
key: d.key,
cx: bbox.x + bbox.width * 0.5,
cy: bbox.y + bbox.height * 0.5,
name: d.name || d.key,
value: d.value,
});
});
const nodes = new Array(centroids.length);
const padX = 10;
const padY = 8;
for (let i = 0; i < centroids.length; i++) {
const centroid = centroids[i];
const cfg = labelConfigMap[centroid.key] || {};
const hLen = cfg.h ?? DEFAULT_H;
const vLen = cfg.v ?? DEFAULT_V;
const angle = cfg.angleDeg ?? DEFAULT_ANGLE;
const { ux: ux1, uy: uy1 } = unitFromAngle(angle);
const { ux: ux2, uy: uy2 } = cfg.side
? unitFromSide(cfg.side, cfg.tiltDeg ?? 0)
: rotateUnit(ux1, uy1, cfg.turnDeg ?? DEFAULT_TURN);
const lines = centroid.value != null
? [String(centroid.value), centroid.name]
: [centroid.name];
const size = measureLines(svgSel, lines, 12, 700);
const w = size.w + padX * 2;
const h = size.h + padY * 2;
const hw = w * 0.5;
const hh = h * 0.5;
const dot = {
x: centroid.cx + (cfg.dotDx ?? 0),
y: centroid.cy + (cfg.dotDy ?? 0),
};
const elbow = {
x: dot.x + hLen * ux1,
y: dot.y + hLen * uy1,
};
const { x: cx, y: cy } = calculateLabelPosition(cfg, elbow, ux2, uy2, hw, hh, vLen);
const clamped = clampToView(cx, cy, w + 20, h + 16);
nodes[i] = {
key: centroid.key,
lines,
w,
h,
x: clamped.x,
y: clamped.y,
elbow,
dot,
};
}
return nodes;
}
/**
* Renders annotation elements (dots and leader lines)
*/
function renderAnnotations(d3, annLayer, nodes, leaderColor, labelConfigMap) {
const ann = annLayer
.selectAll("g.ann")
.data(nodes, (d) => d.key)
.join("g")
.attr("class", "ann");
ann
.selectAll("path.leader")
.data((d) => [d])
.join("path")
.attr("class", "leader")
.attr("fill", "none")
.attr("stroke", leaderColor)
.attr("style", `stroke:${leaderColor}`)
.attr("stroke-dasharray", "6 6")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
.attr("stroke-width", 1.5)
.attr("d", function (d) {
const vx = d.x - d.elbow.x;
const vy = d.y - d.elbow.y;
const len = Math.hypot(vx, vy) || 1;
const ux = vx / len;
const uy = vy / len;
const cfg = labelConfigMap[d.key] || {};
let borderPoint;
if (cfg.side) {
borderPoint = borderPointForSide({ x: d.x, y: d.y }, { w: d.w, h: d.h }, cfg.side);
}
else {
const hw = d.w * 0.5;
const hh = d.h * 0.5;
const tEdge = distanceToRectEdgeAlongDir(hw, hh, ux, uy);
borderPoint = { x: d.x - ux * tEdge, y: d.y - uy * tEdge };
}
return `M${d.dot.x},${d.dot.y} L${d.elbow.x},${d.elbow.y} L${borderPoint.x},${borderPoint.y}`;
});
ann
.selectAll("circle")
.data((d) => [d])
.join("circle")
.attr("cx", (d) => d.dot.x)
.attr("cy", (d) => d.dot.y)
.attr("r", 3)
.attr("fill", "#D85050")
.attr("stroke", "#FFF")
.attr("stroke-width", 1.5)
.raise();
return ann;
}
/**
* Renders labels using React components
*/
function renderLabels(d3, ann, data, regionSel, config, onClick, labelRender) {
const dataMap = new Map();
const rootMap = new Map();
const regionPathMap = new Map();
const labelGroupMap = new Map();
const { activeKey } = config;
for (const region of data) {
dataMap.set(region.key, region);
}
regionSel.each(function (d) {
regionPathMap.set(d.key, d3.select(this));
});
const lab = ann
.selectAll("g.label")
.data((d) => [d])
.join("g")
.attr("class", (d) => d.key === activeKey ? "label map-label-active" : "label")
.on("click", (_e, d) => onClick(d.key));
lab.each(function (d) {
const g = d3.select(this);
labelGroupMap.set(d.key, g);
const regionData = dataMap.get(d.key);
if (!regionData)
return;
let foreignObj = g.select("foreignObject");
if (foreignObj.empty()) {
// Set a more generous initial width to allow content to expand naturally
// Use 2x the estimated width to prevent clipping during measurement
const initialWidth = Math.max(d.w * 2, 200);
const initialHeight = d.h;
foreignObj = g
.append("foreignObject")
.attr("x", d.x - initialWidth * 0.5)
.attr("y", d.y - initialHeight * 0.5)
.attr("width", initialWidth)
.attr("height", initialHeight);
}
const container = foreignObj.node();
if (!container)
return;
let root = rootMap.get(d.key);
if (!root) {
const div = document.createElement("div");
// Don't constrain width/height - let content determine size
div.style.width = "max-content";
div.style.height = "auto";
div.style.display = "inline-block";
container.appendChild(div);
root = createRoot(div);
rootMap.set(d.key, root);
}
root.render(React.createElement(LabelRenderer, {
regionData,
labelNode: d,
customRender: labelRender,
onDimensionsChange: (width, height) => {
// Update foreignObject dimensions
foreignObj.attr("width", width).attr("height", height);
// Adjust x position to keep label centered (or adjust based on labelNode.x)
// The x position should be based on the label's center point
foreignObj.attr("x", d.x - width * 0.5);
foreignObj.attr("y", d.y - height * 0.5);
},
}));
});
return {
cleanup: () => {
rootMap.forEach((root) => root.unmount());
rootMap.clear();
},
labelGroupMap,
};
}
export function drawMap({ d3, svg, rootG, data, activeKey, leaderColor, colors, stroke, onClick, labelConfig: customLabelConfig, labelRender, showLabels = true, getPathStyle, }) {
const root = d3.select(rootG);
const svgSel = d3.select(svg);
const regionLayer = root.append("g").attr("class", "regions");
const annLayer = root.append("g").attr("class", "annotations");
const labelConfigMap = { ...defaultLabelConfig, ...customLabelConfig };
const styleConfig = {
colors,
stroke,
activeKey,
getPathStyle,
};
const regionSel = renderRegions(d3, regionLayer, data, styleConfig, onClick);
let labelCleanup;
if (showLabels) {
const nodes = calculateLabelNodes(svgSel, regionSel, labelConfigMap);
const ann = renderAnnotations(d3, annLayer, nodes, leaderColor, labelConfigMap);
const labelResult = renderLabels(d3, ann, data, regionSel, styleConfig, onClick, labelRender);
labelCleanup = labelResult.cleanup;
}
return () => {
regionLayer.remove();
annLayer.remove();
labelCleanup?.();
};
}
//# sourceMappingURL=draw-map.js.map