diff --git a/public/main.js b/public/main.js index c0ac9d11..2f055942 100644 --- a/public/main.js +++ b/public/main.js @@ -650,6 +650,8 @@ async function generate(options) { Provinces.generate(); Provinces.getPoles(); + Labels.generate(); + Rivers.specify(); Lakes.defineNames(); diff --git a/src/modules/labels.ts b/src/modules/labels.ts index f42f018b..548645c5 100644 --- a/src/modules/labels.ts +++ b/src/modules/labels.ts @@ -7,11 +7,7 @@ export interface StateLabelData { type: "state"; stateId: number; text: string; - pathPoints: [number, number][]; - startOffset: number; - fontSize: number; - letterSpacing: number; - transform: string; + fontSize?: number; } export interface BurgLabelData { @@ -32,10 +28,10 @@ export interface CustomLabelData { group: string; text: string; pathPoints: [number, number][]; - startOffset: number; - fontSize: number; - letterSpacing: number; - transform: string; + startOffset?: number; + fontSize?: number; + letterSpacing?: number; + transform?: string; } export type LabelData = StateLabelData | BurgLabelData | CustomLabelData; @@ -52,6 +48,12 @@ class LabelsModule { return existingIds[existingIds.length - 1] + 1; } + generate() : void { + this.clear(); + generateStateLabels(); + generateBurgLabels(); + } + getAll(): LabelData[] { return pack.labels; } @@ -145,4 +147,78 @@ class LabelsModule { } } +/** + * Generate state labels data entries for each state. + * Only stores essential label data; raycast path calculation happens during rendering. + * @param list - Optional array of stateIds to regenerate only those + */ +export function generateStateLabels(list?: number[]): void { + if (!TIME) console.time("generateStateLabels"); + else TIME && console.time("generateStateLabels"); + + const { states } = pack; + const labelsModule = window.Labels; + + // Remove existing state labels that need regeneration + if (list) { + list.forEach((stateId) => labelsModule.removeStateLabel(stateId)); + } else { + labelsModule.removeByType("state"); + } + + // Generate new label entries + for (const state of states) { + if (!state.i || state.removed || state.lock) continue; + if (list && !list.includes(state.i)) continue; + + labelsModule.addStateLabel({ + stateId: state.i, + text: state.name!, + fontSize: 100, + }); + } + + if (!TIME) console.timeEnd("generateStateLabels"); + else TIME && console.timeEnd("generateStateLabels"); +} + +/** + * Generate burg labels data from burgs. + * Populates pack.labels with BurgLabelData for each burg. + */ +export function generateBurgLabels(): void { + if (!TIME) console.time("generateBurgLabels"); + else TIME && console.time("generateBurgLabels"); + + const labelsModule = window.Labels; + + // Remove existing burg labels + labelsModule.removeByType("burg"); + + // Generate new labels for all active burgs + for (const burg of pack.burgs) { + if (!burg.i || burg.removed) continue; + + const group = burg.group || "unmarked"; + + // Get label group offset attributes if they exist (will be set during rendering) + // For now, use defaults - these will be updated during rendering phase + const dx = 0; + const dy = 0; + + labelsModule.addBurgLabel({ + burgId: burg.i, + group, + text: burg.name!, + x: burg.x, + y: burg.y, + dx, + dy, + }); + } + + if (!TIME) console.timeEnd("generateBurgLabels"); + else TIME && console.timeEnd("generateBurgLabels"); +} + window.Labels = new LabelsModule(); diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index 5dc6cc71..86439559 100644 --- a/src/renderers/draw-burg-labels.ts +++ b/src/renderers/draw-burg-labels.ts @@ -1,4 +1,5 @@ import type { Burg } from "../modules/burgs-generator"; +import type { BurgLabelData } from "../modules/labels"; declare global { var drawBurgLabels: () => void; @@ -15,31 +16,42 @@ const burgLabelsRenderer = (): void => { TIME && console.time("drawBurgLabels"); createLabelGroups(); - for (const { name } of options.burgs.groups as BurgGroup[]) { - const burgsInGroup = pack.burgs.filter( - (b) => b.group === name && !b.removed, - ); - if (!burgsInGroup.length) continue; + // Get all burg labels grouped by group name + const burgLabelsByGroup = new Map(); + for (const label of Labels.getByType("burg").map((l) => l as BurgLabelData)) { + if (!burgLabelsByGroup.has(label.group)) { + burgLabelsByGroup.set(label.group, []); + } + burgLabelsByGroup.get(label.group)!.push(label); + } - const labelGroup = burgLabels.select(`#${name}`); + // Render each group and update label offsets from SVG attributes + for (const [groupName, labels] of burgLabelsByGroup) { + const labelGroup = burgLabels.select(`#${groupName}`); if (labelGroup.empty()) continue; - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; + const dxAttr = labelGroup.attr("data-dx"); + const dyAttr = labelGroup.attr("data-dy"); + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; - labelGroup - .selectAll("text") - .data(burgsInGroup) - .enter() - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", (d) => `burgLabel${d.i}`) - .attr("data-id", (d) => d.i!) - .attr("x", (d) => d.x) - .attr("y", (d) => d.y) - .attr("dx", `${dx}em`) - .attr("dy", `${dy}em`) - .text((d) => d.name!); + for (const labelData of labels) { + // Update label data with SVG group offsets + if (labelData.dx !== dx || labelData.dy !== dy) { + Labels.updateLabel(labelData.i, { dx, dy }); + } + + labelGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", `burgLabel${labelData.burgId}`) + .attr("data-id", labelData.burgId) + .attr("x", labelData.x) + .attr("y", labelData.y) + .attr("dx", `${dx}em`) + .attr("dy", `${dy}em`) + .text(labelData.text); + } } TIME && console.timeEnd("drawBurgLabels"); @@ -48,14 +60,40 @@ const burgLabelsRenderer = (): void => { const drawBurgLabelRenderer = (burg: Burg): void => { const labelGroup = burgLabels.select(`#${burg.group}`); if (labelGroup.empty()) { - drawBurgLabels(); + burgLabelsRenderer(); return; // redraw all labels if group is missing } - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; + const dxAttr = labelGroup.attr("data-dx"); + const dyAttr = labelGroup.attr("data-dy"); + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; removeBurgLabelRenderer(burg.i!); + + // Add/update label in data layer + const existingLabel = Labels.getBurgLabel(burg.i!); + if (existingLabel) { + Labels.updateLabel(existingLabel.i, { + text: burg.name!, + x: burg.x, + y: burg.y, + dx, + dy, + }); + } else { + Labels.addBurgLabel({ + burgId: burg.i!, + group: burg.group || "unmarked", + text: burg.name!, + x: burg.x, + y: burg.y, + dx, + dy, + }); + } + + // Render to SVG labelGroup .append("text") .attr("text-rendering", "optimizeSpeed") @@ -71,6 +109,7 @@ const drawBurgLabelRenderer = (burg: Burg): void => { const removeBurgLabelRenderer = (burgId: number): void => { const existingLabel = document.getElementById(`burgLabel${burgId}`); if (existingLabel) existingLabel.remove(); + Labels.removeBurgLabel(burgId); }; function createLabelGroups(): void { diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index acf66c20..a09de10d 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -1,27 +1,50 @@ import { curveNatural, line, max, select } from "d3"; import { - drawPath, - drawPoint, findClosestCell, minmax, rn, round, splitInTwo, } from "../utils"; +import type { StateLabelData } from "../modules/labels"; import { - Ray, raycast, findBestRayPair, - ANGLES + ANGLES, } from "../utils/label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; } -type PathPoints = [number, number][]; +/** + * Helper function to calculate offset width for raycast based on state size + */ +function getOffsetWidth(cellsNumber: number): number { + if (cellsNumber < 40) return 0; + if (cellsNumber < 200) return 5; + return 10; +} -// list - an optional array of stateIds to regenerate +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; +} + +/** + * Render state labels from pack.labels data to SVG. + * Adjusts and fits labels based on layout constraints. + * list - optional array of stateIds to re-render + */ const stateLabelsRenderer = (list?: number[]): void => { TIME && console.time("drawStateLabels"); @@ -29,28 +52,41 @@ const stateLabelsRenderer = (list?: number[]): void => { const layerDisplay = labels.style("display"); labels.style("display", null); - const { cells, states } = pack; - const stateIds = cells.state; + const { states } = pack; + + // Get labels to render + const labelsToRender = list + ? Labels.getAll() + .filter((l) => l.type === "state" && list.includes((l as StateLabelData).stateId)) + .map((l) => l as StateLabelData) + : Labels.getByType("state").map((l) => l as StateLabelData); - const labelPaths = getLabelPaths(); const letterLength = checkExampleLetterLength(); - drawLabelPath(letterLength); + drawLabelPath(letterLength, labelsToRender); // restore labels visibility labels.style("display", layerDisplay); - function getLabelPaths(): [number, PathPoints][] { - const labelPaths: [number, PathPoints][] = []; + function drawLabelPath(letterLength: number, labelDataList: StateLabelData[]): void { + const mode = options.stateLabelsMode || "auto"; + const lineGen = line<[number, number]>().curve(curveNatural); - for (const state of states) { - if (!state.i || state.removed || state.lock) continue; - if (list && !list.includes(state.i)) continue; + const textGroup = select("g#labels > g#states"); + const pathGroup = select( + "defs > g#deftemp > g#textPaths", + ); + for (const labelData of labelDataList) { + const state = states[labelData.stateId]; + if (!state.i || state.removed) + throw new Error("State must not be neutral or removed"); + + // Calculate pathPoints using raycast algorithm (recalculated on each draw) const offset = getOffsetWidth(state.cells!); const maxLakeSize = state.cells! / 20; const [x0, y0] = state.pole!; - const rays: Ray[] = ANGLES.map(({ angle, dx, dy }) => { + const rays = ANGLES.map(({ angle, dx, dy }) => { const { length, x, y } = raycast({ stateId: state.i, x0, @@ -64,61 +100,20 @@ const stateLabelsRenderer = (list?: number[]): void => { }); const [ray1, ray2] = findBestRayPair(rays); - const pathPoints: PathPoints = [ + const pathPoints: [number, number][] = [ [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(); + textGroup.select(`#stateLabel${labelData.stateId}`).remove(); + pathGroup.select(`#textPath_stateLabel${labelData.stateId}`).remove(); const textPath = pathGroup .append("path") .attr("d", round(lineGen(pathPoints) || "")) - .attr("id", `textPath_stateLabel${stateId}`); + .attr("id", `textPath_stateLabel${labelData.stateId}`); const pathLength = (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters @@ -129,6 +124,9 @@ const stateLabelsRenderer = (list?: number[]): void => { pathLength, ); + // Update label data with font size + Labels.updateLabel(labelData.i, { fontSize: ratio }); + // prolongate path if it's too short const longestLineLength = max(lines.map((line) => line.length)) || 0; if (pathLength && pathLength < longestLineLength) { @@ -149,7 +147,7 @@ const stateLabelsRenderer = (list?: number[]): void => { const textElement = textGroup .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("id", `stateLabel${stateId}`) + .attr("id", `stateLabel${labelData.stateId}`) .append("textPath") .attr("startOffset", "50%") .attr("font-size", `${ratio}%`) @@ -163,12 +161,16 @@ const stateLabelsRenderer = (list?: number[]): void => { textElement.insertAdjacentHTML("afterbegin", spans.join("")); const { width, height } = textElement.getBBox(); - textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); + textElement.setAttribute("href", `#textPath_stateLabel${labelData.stateId}`); + const stateIds = pack.cells.state; 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 [[x1, y1], [x2, y2]] = [ + pathPoints.at(0)!, + pathPoints.at(-1)!, + ]; const angleRad = Math.atan2(y2 - y1, x2 - x1); const isInsideState = checkIfInsideState( @@ -177,7 +179,7 @@ const stateLabelsRenderer = (list?: number[]): void => { width / 2, height / 2, stateIds, - stateId, + labelData.stateId, ); if (isInsideState) continue; @@ -187,6 +189,7 @@ const stateLabelsRenderer = (list?: number[]): void => { ? state.fullName! : state.name!; textElement.innerHTML = `${text}`; + Labels.updateLabel(labelData.i, { text }); const correctedRatio = minmax( rn((pathLength / text.length) * 50), @@ -194,15 +197,10 @@ const stateLabelsRenderer = (list?: number[]): void => { 130, ); textElement.setAttribute("font-size", `${correctedRatio}%`); + Labels.updateLabel(labelData.i, { fontSize: correctedRatio }); } } - function getOffsetWidth(cellsNumber: number): number { - if (cellsNumber < 40) return 0; - if (cellsNumber < 200) return 5; - return 10; - } - function getLinesAndRatio( mode: string, name: string,