UNPKG

uzb-d3-map

Version:

A flexible, customizable SVG map component for Uzbekistan built with D3 and React

319 lines 11.7 kB
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