import { curveNatural, line, max, select } from "d3"; import { drawPath, drawPoint, findClosestCell, minmax, rn, round, splitInTwo, } from "../utils"; import { Ray, raycast, findBestRayPair, ANGLES } from "../utils/label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; } type PathPoints = [number, number][]; // list - an optional array of stateIds to regenerate const stateLabelsRenderer = (list?: number[]): void => { TIME && console.time("drawStateLabels"); // temporary make the labels visible const layerDisplay = labels.style("display"); labels.style("display", null); const { cells, states } = pack; const stateIds = cells.state; const labelPaths = getLabelPaths(); const letterLength = checkExampleLetterLength(); drawLabelPath(letterLength); // restore labels visibility labels.style("display", layerDisplay); function getLabelPaths(): [number, PathPoints][] { const labelPaths: [number, PathPoints][] = []; for (const state of states) { if (!state.i || state.removed || state.lock) continue; if (list && !list.includes(state.i)) continue; const offset = getOffsetWidth(state.cells!); const maxLakeSize = state.cells! / 20; const [x0, y0] = state.pole!; const rays: Ray[] = ANGLES.map(({ angle, dx, dy }) => { const { length, x, y } = raycast({ stateId: state.i, x0, y0, dx, dy, maxLakeSize, offset, }); return { angle, length, x, y }; }); const [ray1, ray2] = findBestRayPair(rays); const pathPoints: PathPoints = [ [ray1.x, ray1.y], state.pole!, [ray2.x, ray2.y], ]; if (ray1.x > ray2.x) pathPoints.reverse(); if (DEBUG.stateLabels) { drawPoint(state.pole!, { color: "black", radius: 1 }); drawPath(pathPoints, { color: "black", width: 0.2 }); } labelPaths.push([state.i, pathPoints]); } return labelPaths; } function checkExampleLetterLength(): number { const textGroup = select("g#labels > g#states"); const testLabel = textGroup .append("text") .attr("x", 0) .attr("y", 0) .text("Example"); const letterLength = (testLabel.node() as SVGTextElement).getComputedTextLength() / 7; // approximate length of 1 letter testLabel.remove(); return letterLength; } function drawLabelPath(letterLength: number): void { const mode = options.stateLabelsMode || "auto"; const lineGen = line<[number, number]>().curve(curveNatural); const textGroup = select("g#labels > g#states"); const pathGroup = select( "defs > g#deftemp > g#textPaths", ); for (const [stateId, pathPoints] of labelPaths) { const state = states[stateId]; if (!state.i || state.removed) throw new Error("State must not be neutral or removed"); if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points"); textGroup.select(`#stateLabel${stateId}`).remove(); pathGroup.select(`#textPath_stateLabel${stateId}`).remove(); const textPath = pathGroup .append("path") .attr("d", round(lineGen(pathPoints) || "")) .attr("id", `textPath_stateLabel${stateId}`); const pathLength = (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters const [lines, ratio] = getLinesAndRatio( mode, state.name!, state.fullName!, pathLength, ); // prolongate path if it's too short const longestLineLength = max(lines.map((line) => line.length)) || 0; if (pathLength && pathLength < longestLineLength) { const [x1, y1] = pathPoints.at(0)!; const [x2, y2] = pathPoints.at(-1)!; const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; const mod = longestLineLength / pathLength; pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; pathPoints[pathPoints.length - 1] = [ x2 - dx + dx * mod, y2 - dy + dy * mod, ]; textPath.attr("d", round(lineGen(pathPoints) || "")); } const textElement = textGroup .append("text") .attr("text-rendering", "optimizeSpeed") .attr("id", `stateLabel${stateId}`) .append("textPath") .attr("startOffset", "50%") .attr("font-size", `${ratio}%`) .node() as SVGTextPathElement; const top = (lines.length - 1) / -2; // y offset const spans = lines.map( (lineText, index) => `${lineText}`, ); textElement.insertAdjacentHTML("afterbegin", spans.join("")); const { width, height } = textElement.getBBox(); textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); if (mode === "full" || lines.length === 1) continue; // check if label fits state boundaries. If no, replace it with short name const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; const angleRad = Math.atan2(y2 - y1, x2 - x1); const isInsideState = checkIfInsideState( textElement, angleRad, width / 2, height / 2, stateIds, stateId, ); if (isInsideState) continue; // replace name to one-liner const text = pathLength > state.fullName!.length * 1.8 ? state.fullName! : state.name!; textElement.innerHTML = `${text}`; const correctedRatio = minmax( rn((pathLength / text.length) * 50), 50, 130, ); textElement.setAttribute("font-size", `${correctedRatio}%`); } } function getOffsetWidth(cellsNumber: number): number { if (cellsNumber < 40) return 0; if (cellsNumber < 200) return 5; return 10; } function getLinesAndRatio( mode: string, name: string, fullName: string, pathLength: number, ): [string[], number] { if (mode === "short") return getShortOneLine(); if (pathLength > fullName.length * 2) return getFullOneLine(); return getFullTwoLines(); function getShortOneLine(): [string[], number] { const ratio = pathLength / name.length; return [[name], minmax(rn(ratio * 60), 50, 150)]; } function getFullOneLine(): [string[], number] { const ratio = pathLength / fullName.length; return [[fullName], minmax(rn(ratio * 70), 70, 170)]; } function getFullTwoLines(): [string[], number] { const lines = splitInTwo(fullName); const longestLineLength = max(lines.map((line) => line.length)) || 0; const ratio = pathLength / longestLineLength; return [lines, minmax(rn(ratio * 60), 70, 150)]; } } // check whether multi-lined label is mostly inside the state. If no, replace it with short name label function checkIfInsideState( textElement: SVGTextPathElement, angleRad: number, halfwidth: number, halfheight: number, stateIds: number[], stateId: number, ): boolean { const bbox = textElement.getBBox(); const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; const points: [number, number][] = [ [-halfwidth, -halfheight], [+halfwidth, -halfheight], [+halfwidth, halfheight], [-halfwidth, halfheight], [0, halfheight], [0, -halfheight], ]; const sin = Math.sin(angleRad); const cos = Math.cos(angleRad); const rotatedPoints = points.map(([x, y]): [number, number] => [ cx + x * cos - y * sin, cy + x * sin + y * cos, ]); let pointsInside = 0; for (const [x, y] of rotatedPoints) { const isInside = stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; if (isInside) pointsInside++; if (pointsInside > 4) return true; } return false; } TIME && console.timeEnd("drawStateLabels"); }; window.drawStateLabels = stateLabelsRenderer;