From 7bf33b6e654205ab9f275aa3bf3faf16c0b678aa Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 10 Feb 2026 22:38:38 +0100 Subject: [PATCH 01/19] feat: add labels module and integrate into PackedGraph --- src/modules/index.ts | 1 + src/modules/labels.ts | 152 +++++++++++++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 2 + 3 files changed, 155 insertions(+) create mode 100644 src/modules/labels.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index a9ebf2b81..46ee67595 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,4 +12,5 @@ import "./routes-generator"; import "./states-generator"; import "./zones-generator"; import "./religions-generator"; +import "./labels"; import "./provinces-generator"; diff --git a/src/modules/labels.ts b/src/modules/labels.ts new file mode 100644 index 000000000..a68622631 --- /dev/null +++ b/src/modules/labels.ts @@ -0,0 +1,152 @@ +declare global { + var Labels: LabelsModule; +} + +// --- Types --- + +export interface StateLabelData { + i: number; + type: "state"; + stateId: number; + text: string; + pathPoints: [number, number][]; + startOffset: number; + fontSize: number; + letterSpacing: number; + transform: string; +} + +export interface BurgLabelData { + i: number; + type: "burg"; + burgId: number; + group: string; + text: string; + x: number; + y: number; + dx: number; + dy: number; +} + +export interface CustomLabelData { + i: number; + type: "custom"; + group: string; + text: string; + pathPoints: [number, number][]; + startOffset: number; + fontSize: number; + letterSpacing: number; + transform: string; +} + +export type LabelData = StateLabelData | BurgLabelData | CustomLabelData; + +// --- Implementation --- + +class LabelsModule { + private getNextId(): number { + const labels = pack.labels; + if (labels.length === 0) return 0; + + const existingIds = labels.map((l) => l.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + getAll(): LabelData[] { + return pack.labels; + } + + get(id: number): LabelData | undefined { + return pack.labels.find((l) => l.i === id); + } + + getByType(type: LabelData["type"]): LabelData[] { + return pack.labels.filter((l) => l.type === type); + } + + getByGroup(group: string): LabelData[] { + return pack.labels.filter( + (l) => (l.type === "burg" || l.type === "custom") && l.group === group, + ); + } + + getStateLabel(stateId: number): StateLabelData | undefined { + return pack.labels.find( + (l) => l.type === "state" && l.stateId === stateId, + ) as StateLabelData | undefined; + } + + getBurgLabel(burgId: number): BurgLabelData | undefined { + return pack.labels.find( + (l) => l.type === "burg" && l.burgId === burgId, + ) as BurgLabelData | undefined; + } + + addStateLabel( + data: Omit, + ): StateLabelData { + const label: StateLabelData = { i: this.getNextId(), type: "state", ...data }; + pack.labels.push(label); + return label; + } + + addBurgLabel(data: Omit): BurgLabelData { + const label: BurgLabelData = { i: this.getNextId(), type: "burg", ...data }; + pack.labels.push(label); + return label; + } + + addCustomLabel( + data: Omit, + ): CustomLabelData { + const label: CustomLabelData = { i: this.getNextId(), type: "custom", ...data }; + pack.labels.push(label); + return label; + } + + updateLabel(id: number, updates: Partial): void { + const label = pack.labels.find((l) => l.i === id); + if (!label) return; + Object.assign(label, updates, { i: label.i, type: label.type }); + } + + removeLabel(id: number): void { + const index = pack.labels.findIndex((l) => l.i === id); + if (index !== -1) pack.labels.splice(index, 1); + } + + removeByType(type: LabelData["type"]): void { + pack.labels = pack.labels.filter((l) => l.type !== type); + } + + removeByGroup(group: string): void { + pack.labels = pack.labels.filter( + (l) => + !((l.type === "burg" || l.type === "custom") && l.group === group), + ); + } + + removeStateLabel(stateId: number): void { + const index = pack.labels.findIndex( + (l) => l.type === "state" && l.stateId === stateId, + ); + if (index !== -1) pack.labels.splice(index, 1); + } + + removeBurgLabel(burgId: number): void { + const index = pack.labels.findIndex( + (l) => l.type === "burg" && l.burgId === burgId, + ); + if (index !== -1) pack.labels.splice(index, 1); + } + + clear(): void { + pack.labels = []; + } +} + +window.Labels = new LabelsModule(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index b8749f0a5..f54d81576 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -5,6 +5,7 @@ import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; +import type { LabelData } from "../modules/labels"; import type { Zone } from "../modules/zones-generator"; type TypedArray = @@ -62,5 +63,6 @@ export interface PackedGraph { zones: Zone[]; markers: any[]; ice: any[]; + labels: LabelData[]; provinces: Province[]; } From dac231f91416db413355267768648e292a465c00 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 10 Feb 2026 22:53:32 +0100 Subject: [PATCH 02/19] refactor: clean up label-related code and introduce raycasting utilities --- src/modules/labels.ts | 4 - src/renderers/draw-state-labels.ts | 179 ++-------------------------- src/utils/label-raycast.ts | 185 +++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 175 deletions(-) create mode 100644 src/utils/label-raycast.ts diff --git a/src/modules/labels.ts b/src/modules/labels.ts index a68622631..f42f018b4 100644 --- a/src/modules/labels.ts +++ b/src/modules/labels.ts @@ -2,8 +2,6 @@ declare global { var Labels: LabelsModule; } -// --- Types --- - export interface StateLabelData { i: number; type: "state"; @@ -42,8 +40,6 @@ export interface CustomLabelData { export type LabelData = StateLabelData | BurgLabelData | CustomLabelData; -// --- Implementation --- - class LabelsModule { private getNextId(): number { const labels = pack.labels; diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index 24528d450..acf66c207 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -8,24 +8,17 @@ import { round, splitInTwo, } from "../utils"; +import { + Ray, + raycast, + findBestRayPair, + ANGLES +} from "../utils/label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; } -interface Ray { - angle: number; - length: number; - x: number; - y: number; -} - -interface AngleData { - angle: number; - dx: number; - dy: number; -} - type PathPoints = [number, number][]; // list - an optional array of stateIds to regenerate @@ -36,18 +29,9 @@ const stateLabelsRenderer = (list?: number[]): void => { const layerDisplay = labels.style("display"); labels.style("display", null); - const { cells, states, features } = pack; + const { cells, states } = pack; const stateIds = cells.state; - // increase step to 15 or 30 to make it faster and more horyzontal - // decrease step to 5 to improve accuracy - const ANGLE_STEP = 9; - const angles = precalculateAngles(ANGLE_STEP); - - const LENGTH_START = 5; - const LENGTH_STEP = 5; - const LENGTH_MAX = 300; - const labelPaths = getLabelPaths(); const letterLength = checkExampleLetterLength(); drawLabelPath(letterLength); @@ -66,7 +50,7 @@ const stateLabelsRenderer = (list?: number[]): void => { const maxLakeSize = state.cells! / 20; const [x0, y0] = state.pole!; - const rays: Ray[] = angles.map(({ angle, dx, dy }) => { + const rays: Ray[] = ANGLES.map(({ angle, dx, dy }) => { const { length, x, y } = raycast({ stateId: state.i, x0, @@ -219,153 +203,6 @@ const stateLabelsRenderer = (list?: number[]): void => { return 10; } - function precalculateAngles(step: number): AngleData[] { - const angles: AngleData[] = []; - const RAD = Math.PI / 180; - - for (let angle = 0; angle < 360; angle += step) { - const dx = Math.cos(angle * RAD); - const dy = Math.sin(angle * RAD); - angles.push({ angle, dx, dy }); - } - - return angles; - } - - function raycast({ - stateId, - x0, - y0, - dx, - dy, - maxLakeSize, - offset, - }: { - stateId: number; - x0: number; - y0: number; - dx: number; - dy: number; - maxLakeSize: number; - offset: number; - }): { length: number; x: number; y: number } { - let ray = { length: 0, x: x0, y: y0 }; - - for ( - let length = LENGTH_START; - length < LENGTH_MAX; - length += LENGTH_STEP - ) { - const [x, y] = [x0 + length * dx, y0 + length * dy]; - // offset points are perpendicular to the ray - const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; - const offset2: [number, number] = [x + dy * offset, y + -dx * offset]; - - if (DEBUG.stateLabels) { - drawPoint([x, y], { - color: isInsideState(x, y) ? "blue" : "red", - radius: 0.8, - }); - drawPoint(offset1, { - color: isInsideState(...offset1) ? "blue" : "red", - radius: 0.4, - }); - drawPoint(offset2, { - color: isInsideState(...offset2) ? "blue" : "red", - radius: 0.4, - }); - } - - const inState = - isInsideState(x, y) && - isInsideState(...offset1) && - isInsideState(...offset2); - if (!inState) break; - ray = { length, x, y }; - } - - return ray; - - function isInsideState(x: number, y: number): boolean { - if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; - const cellId = findClosestCell(x, y, undefined, pack) as number; - - const feature = features[cells.f[cellId]]; - if (feature.type === "lake") - return isInnerLake(feature) || isSmallLake(feature); - - return stateIds[cellId] === stateId; - } - - function isInnerLake(feature: { shoreline: number[] }): boolean { - return feature.shoreline.every((cellId) => stateIds[cellId] === stateId); - } - - function isSmallLake(feature: { cells: number }): boolean { - return feature.cells <= maxLakeSize; - } - } - - function findBestRayPair(rays: Ray[]): [Ray, Ray] { - let bestPair: [Ray, Ray] | null = null; - let bestScore = -Infinity; - - for (let i = 0; i < rays.length; i++) { - const score1 = rays[i].length * scoreRayAngle(rays[i].angle); - - for (let j = i + 1; j < rays.length; j++) { - const score2 = rays[j].length * scoreRayAngle(rays[j].angle); - const pairScore = - (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); - - if (pairScore > bestScore) { - bestScore = pairScore; - bestPair = [rays[i], rays[j]]; - } - } - } - - return bestPair!; - } - - function scoreRayAngle(angle: number): number { - const normalizedAngle = Math.abs(angle % 180); // [0, 180] - const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] - - if (horizontality === 1) return 1; // Best: horizontal - if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted - if (horizontality >= 0.5) return 0.6; // Good: moderate slant - if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted - if (horizontality >= 0.15) return 0.2; // Poor: almost vertical - return 0.1; // Very poor: almost vertical - } - - function scoreCurvature(angle1: number, angle2: number): number { - const delta = getAngleDelta(angle1, angle2); - const similarity = evaluateArc(angle1, angle2); - - if (delta === 180) return 1; // straight line: best - if (delta < 90) return 0; // acute: not allowed - if (delta < 120) return 0.6 * similarity; - if (delta < 140) return 0.7 * similarity; - if (delta < 160) return 0.8 * similarity; - - return similarity; - } - - function getAngleDelta(angle1: number, angle2: number): number { - let delta = Math.abs(angle1 - angle2) % 360; - if (delta > 180) delta = 360 - delta; // [0, 180] - return delta; - } - - // compute arc similarity towards x-axis - function evaluateArc(angle1: number, angle2: number): number { - const proximity1 = Math.abs((angle1 % 180) - 90); - const proximity2 = Math.abs((angle2 % 180) - 90); - return 1 - Math.abs(proximity1 - proximity2) / 90; - } - function getLinesAndRatio( mode: string, name: string, diff --git a/src/utils/label-raycast.ts b/src/utils/label-raycast.ts new file mode 100644 index 000000000..a5d96870b --- /dev/null +++ b/src/utils/label-raycast.ts @@ -0,0 +1,185 @@ +import { findClosestCell } from "./index"; + +export interface Ray { + angle: number; + length: number; + x: number; + y: number; +} + +export interface AngleData { + angle: number; + dx: number; + dy: number; +} + +interface RaycastParams { + stateId: number; + x0: number; + y0: number; + dx: number; + dy: number; + maxLakeSize: number; + offset: number; +} + +// increase step to 15 or 30 to make it faster and more horyzontal +// decrease step to 5 to improve accuracy +const ANGLE_STEP = 9; +export const ANGLES = precalculateAngles(ANGLE_STEP); + +const LENGTH_START = 5; +const LENGTH_STEP = 5; +const LENGTH_MAX = 300; + +/** + * Cast a ray from a point in a given direction until it exits a state. + * Checks both the ray point and offset points perpendicular to it. + */ +export function raycast({ + stateId, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, +}: RaycastParams): { length: number; x: number; y: number } { + const { cells, features } = pack; + const stateIds = cells.state; + let ray = { length: 0, x: x0, y: y0 }; + + for ( + let length = LENGTH_START; + length < LENGTH_MAX; + length += LENGTH_STEP + ) { + const [x, y] = [x0 + length * dx, y0 + length * dy]; + // offset points are perpendicular to the ray + const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; + const offset2: [number, number] = [x + dy * offset, y + -dx * offset]; + + const inState = + isInsideState(x, y, stateId) && + isInsideState(...offset1, stateId) && + isInsideState(...offset2, stateId); + if (!inState) break; + ray = { length, x, y }; + } + + return ray; + + function isInsideState(x: number, y: number, stateId: number): boolean { + if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; + const cellId = findClosestCell(x, y, undefined, pack) as number; + + const feature = features[cells.f[cellId]]; + if (feature.type === "lake") + return isInnerLake(feature) || isSmallLake(feature); + + return stateIds[cellId] === stateId; + } + + function isInnerLake(feature: { shoreline: number[] }): boolean { + return feature.shoreline.every((cellId) => stateIds[cellId] === stateId); + } + + function isSmallLake(feature: { cells: number }): boolean { + return feature.cells <= maxLakeSize; + } +} + +/** + * Score a ray angle based on how horizontal it is. + * Horizontal rays (0° or 180°) are preferred for label placement. + */ +export function scoreRayAngle(angle: number): number { + const normalizedAngle = Math.abs(angle % 180); // [0, 180] + const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] + + if (horizontality === 1) return 1; // Best: horizontal + if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted + if (horizontality >= 0.5) return 0.6; // Good: moderate slant + if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted + if (horizontality >= 0.15) return 0.2; // Poor: almost vertical + return 0.1; // Very poor: almost vertical +} + +/** + * Calculate the angle delta between two angles (0-180 degrees). + */ +export function getAngleDelta(angle1: number, angle2: number): number { + let delta = Math.abs(angle1 - angle2) % 360; + if (delta > 180) delta = 360 - delta; // [0, 180] + return delta; +} + +/** + * Evaluate how similar the arc between two angles is. + * Computes proximity of both angles towards the x-axis. + */ +export function evaluateArc(angle1: number, angle2: number): number { + const proximity1 = Math.abs((angle1 % 180) - 90); + const proximity2 = Math.abs((angle2 % 180) - 90); + return 1 - Math.abs(proximity1 - proximity2) / 90; +} + +/** + * Score a ray pair based on the delta angle between them and their arc similarity. + * Penalizes acute angles (<90°), favors straight lines (180°). + */ +export function scoreCurvature(angle1: number, angle2: number): number { + const delta = getAngleDelta(angle1, angle2); + const similarity = evaluateArc(angle1, angle2); + + if (delta === 180) return 1; // straight line: best + if (delta < 90) return 0; // acute: not allowed + if (delta < 120) return 0.6 * similarity; + if (delta < 140) return 0.7 * similarity; + if (delta < 160) return 0.8 * similarity; + + return similarity; +} + +/** + * Precompute angles and their vector components for raycast directions. + * Used to sample rays around a point at regular angular intervals. + */ +function precalculateAngles(step: number): AngleData[] { + const angles: AngleData[] = []; + const RAD = Math.PI / 180; + + for (let angle = 0; angle < 360; angle += step) { + const dx = Math.cos(angle * RAD); + const dy = Math.sin(angle * RAD); + angles.push({ angle, dx, dy }); + } + + return angles; +} + +/** + * Find the best pair of rays for label placement along a curved path. + * Prefers horizontal rays and well-separated angles. + */ +export function findBestRayPair(rays: Ray[]): [Ray, Ray] { + let bestPair: [Ray, Ray] | null = null; + let bestScore = -Infinity; + + for (let i = 0; i < rays.length; i++) { + const score1 = rays[i].length * scoreRayAngle(rays[i].angle); + + for (let j = i + 1; j < rays.length; j++) { + const score2 = rays[j].length * scoreRayAngle(rays[j].angle); + const pairScore = + (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); + + if (pairScore > bestScore) { + bestScore = pairScore; + bestPair = [rays[i], rays[j]]; + } + } + } + + return bestPair!; +} From c467f87df552c93e9fd5ec6c26823d7b2bc2c1b7 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 10 Feb 2026 22:56:48 +0100 Subject: [PATCH 03/19] refactor: change exported functions to internal functions in label-raycast utility --- src/utils/label-raycast.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/label-raycast.ts b/src/utils/label-raycast.ts index a5d96870b..05e760430 100644 --- a/src/utils/label-raycast.ts +++ b/src/utils/label-raycast.ts @@ -7,7 +7,7 @@ export interface Ray { y: number; } -export interface AngleData { +interface AngleData { angle: number; dx: number; dy: number; @@ -93,7 +93,7 @@ export function raycast({ * Score a ray angle based on how horizontal it is. * Horizontal rays (0° or 180°) are preferred for label placement. */ -export function scoreRayAngle(angle: number): number { +function scoreRayAngle(angle: number): number { const normalizedAngle = Math.abs(angle % 180); // [0, 180] const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] @@ -108,7 +108,7 @@ export function scoreRayAngle(angle: number): number { /** * Calculate the angle delta between two angles (0-180 degrees). */ -export function getAngleDelta(angle1: number, angle2: number): number { +function getAngleDelta(angle1: number, angle2: number): number { let delta = Math.abs(angle1 - angle2) % 360; if (delta > 180) delta = 360 - delta; // [0, 180] return delta; @@ -118,7 +118,7 @@ export function getAngleDelta(angle1: number, angle2: number): number { * Evaluate how similar the arc between two angles is. * Computes proximity of both angles towards the x-axis. */ -export function evaluateArc(angle1: number, angle2: number): number { +function evaluateArc(angle1: number, angle2: number): number { const proximity1 = Math.abs((angle1 % 180) - 90); const proximity2 = Math.abs((angle2 % 180) - 90); return 1 - Math.abs(proximity1 - proximity2) / 90; @@ -128,7 +128,7 @@ export function evaluateArc(angle1: number, angle2: number): number { * Score a ray pair based on the delta angle between them and their arc similarity. * Penalizes acute angles (<90°), favors straight lines (180°). */ -export function scoreCurvature(angle1: number, angle2: number): number { +function scoreCurvature(angle1: number, angle2: number): number { const delta = getAngleDelta(angle1, angle2); const similarity = evaluateArc(angle1, angle2); From 94b638f3cb571d1b85dbdd44633e80bd5fd0d0e7 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Wed, 11 Feb 2026 22:01:57 +0100 Subject: [PATCH 04/19] feat: integrate label generation into main flow and enhance label data handling --- public/main.js | 2 + src/modules/labels.ts | 94 +++++++++++++++++-- src/renderers/draw-burg-labels.ts | 89 ++++++++++++------ src/renderers/draw-state-labels.ts | 140 ++++++++++++++--------------- 4 files changed, 220 insertions(+), 105 deletions(-) diff --git a/public/main.js b/public/main.js index c0ac9d110..2f055942e 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 f42f018b4..548645c5b 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 5dc6cc713..864395599 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; - - 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!); + const dxAttr = labelGroup.attr("data-dx"); + const dyAttr = labelGroup.attr("data-dy"); + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; + + 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 acf66c207..a09de10d7 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; +} + +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; +} -// list - an optional array of stateIds to regenerate +/** + * 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, From 689fef0858e32c5025548588ed310e25cba51e9a Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Fri, 13 Feb 2026 18:07:12 +0100 Subject: [PATCH 05/19] feat: enhance label editing functionality and improve data model synchronization --- public/modules/ui/labels-editor.js | 118 +++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/public/modules/ui/labels-editor.js b/public/modules/ui/labels-editor.js index 8c47ec99e..32d1c2c74 100644 --- a/public/modules/ui/labels-editor.js +++ b/public/modules/ui/labels-editor.js @@ -1,4 +1,55 @@ "use strict"; +let currentLabelData = null; + +// Helper: extract control points from an SVG path element +function extractPathPoints(pathElement) { + if (!pathElement) return []; + const l = pathElement.getTotalLength(); + if (!l) return []; + const points = []; + const increment = l / Math.max(Math.ceil(l / 200), 2); + for (let i = 0; i <= l; i += increment) { + const point = pathElement.getPointAtLength(i); + points.push([point.x, point.y]); + } + return points; +} + +// Helper: find label data from the Labels data model for an SVG text element +function getLabelData(textElement) { + const id = textElement.id || ""; + if (id.startsWith("stateLabel")) { + return Labels.getStateLabel(+id.slice(10)); + } + // Custom labels: check for existing data-label-id attribute + const dataLabelId = textElement.getAttribute("data-label-id"); + if (dataLabelId != null) { + const existing = Labels.get(+dataLabelId); + if (existing) return existing; + // Data was cleared (e.g., map regenerated) — recreate + textElement.removeAttribute("data-label-id"); + } + // No data entry found — create one from SVG state (migration path) + return createCustomLabelDataFromSvg(textElement); +} + +// Helper: create a CustomLabelData entry from existing SVG elements +function createCustomLabelDataFromSvg(textElement) { + const textPathEl = textElement.querySelector("textPath"); + if (!textPathEl) return null; + const group = textElement.parentNode.id; + const text = [...textPathEl.querySelectorAll("tspan")].map(t => t.textContent).join("|"); + const pathEl = byId("textPath_" + textElement.id); + const pathPoints = extractPathPoints(pathEl); + const startOffset = parseFloat(textPathEl.getAttribute("startOffset")) || 50; + const fontSize = parseFloat(textPathEl.getAttribute("font-size")) || 100; + const letterSpacing = parseFloat(textPathEl.getAttribute("letter-spacing") || "0"); + const transform = textElement.getAttribute("transform") || undefined; + const label = Labels.addCustomLabel({ group, text, pathPoints, startOffset, fontSize, letterSpacing, transform }); + textElement.setAttribute("data-label-id", String(label.i)); + return label; +} + function editLabel() { if (customization) return; closeDialogs(); @@ -10,11 +61,14 @@ function editLabel() { elSelected = d3.select(text).call(d3.drag().on("start", dragLabel)).classed("draggable", true); viewbox.on("touchmove mousemove", showEditorTips); + // Resolve label data from the data model + currentLabelData = getLabelData(text); + $("#labelEditor").dialog({ title: "Edit Label", resizable: false, width: fitContent(), - position: {my: "center top+10", at: "bottom", of: text, collision: "fit"}, + position: { my: "center top+10", at: "bottom", of: text, collision: "fit" }, close: closeLabelEditor }); @@ -82,11 +136,20 @@ function editLabel() { } function updateValues(textPath) { - byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|"); - byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset")); - byId("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size")); - let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0; - byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize); + if (currentLabelData && currentLabelData.type === "custom") { + // Custom labels: read all values from data model + byId("labelText").value = currentLabelData.text || ""; + byId("labelStartOffset").value = currentLabelData.startOffset || 50; + byId("labelRelativeSize").value = currentLabelData.fontSize || 100; + byId("labelLetterSpacingSize").value = currentLabelData.letterSpacing || 0; + } else { + // State labels and fallback: read from SVG, use data model fontSize if available + byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|"); + byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset")) || 50; + byId("labelRelativeSize").value = (currentLabelData && currentLabelData.fontSize) || parseFloat(textPath.getAttribute("font-size")) || 100; + let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0; + byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize); + } } function drawControlPointsAndLine() { @@ -128,11 +191,13 @@ function editLabel() { .select("#controlPoints") .selectAll("circle") .each(function () { - points.push([this.getAttribute("cx"), this.getAttribute("cy")]); + points.push([+this.getAttribute("cx"), +this.getAttribute("cy")]); }); const d = round(lineGen(points)); path.setAttribute("d", d); debug.select("#controlPoints > path").attr("d", d); + // Sync path control points back to data model + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { pathPoints: points }); } function clickControlPoint() { @@ -187,6 +252,7 @@ function editLabel() { const transform = `translate(${dx + x},${dy + y})`; elSelected.attr("transform", transform); debug.select("#controlPoints").attr("transform", transform); + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { transform }); }); } @@ -205,6 +271,9 @@ function editLabel() { function changeGroup() { byId(this.value).appendChild(elSelected.node()); + if (currentLabelData && currentLabelData.type === "custom") { + Labels.updateLabel(currentLabelData.i, { group: this.value }); + } } function toggleNewGroupInput() { @@ -243,6 +312,9 @@ function editLabel() { if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) { byId("labelGroupSelect").selectedOptions[0].remove(); byId("labelGroupSelect").options.add(new Option(group, group, false, true)); + // Update data model for labels in the old group + const oldGroupName = oldGroup.id; + Labels.getByGroup(oldGroupName).forEach(l => Labels.updateLabel(l.i, { group })); oldGroup.id = group; toggleNewGroupInput(); byId("labelGroupInput").value = ""; @@ -254,6 +326,10 @@ function editLabel() { newGroup.id = group; byId("labelGroupSelect").options.add(new Option(group, group, false, true)); byId(group).appendChild(elSelected.node()); + // Update data model group for the moved label + if (currentLabelData && currentLabelData.type === "custom") { + Labels.updateLabel(currentLabelData.i, { group }); + } toggleNewGroupInput(); byId("labelGroupInput").value = ""; @@ -263,9 +339,8 @@ function editLabel() { const group = elSelected.node().parentNode.id; const basic = group === "states" || group === "addedLabels"; const count = elSelected.node().parentNode.childElementCount; - alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${ - basic ? "all elements in the group" : "the entire label group" - }?

Labels to be + alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${basic ? "all elements in the group" : "the entire label group" + }?

Labels to be removed: ${count}`; $("#alert").dialog({ resizable: false, @@ -275,6 +350,12 @@ function editLabel() { $(this).dialog("close"); $("#labelEditor").dialog("close"); hideGroupSection(); + // Remove from data model + if (basic && group === "states") { + Labels.removeByType("state"); + } else { + Labels.removeByGroup(group); + } labels .select("#" + group) .selectAll("text") @@ -311,15 +392,17 @@ function editLabel() { el.innerHTML = lines.map((line, index) => `${line}`).join(""); } else el.innerHTML = `${lines}`; + // Update data model + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { text: input }); + if (elSelected.attr("id").slice(0, 10) === "stateLabel") tip("Use States Editor to change an actual state name, not just a label", false, "warning"); } function generateRandomName() { let name = ""; - if (elSelected.attr("id").slice(0, 10) === "stateLabel") { - const id = +elSelected.attr("id").slice(10); - const culture = pack.states[id].culture; + if (currentLabelData && currentLabelData.type === "state") { + const culture = pack.states[currentLabelData.stateId].culture; name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture); } else { const box = elSelected.node().getBBox(); @@ -358,17 +441,20 @@ function editLabel() { function changeStartOffset() { elSelected.select("textPath").attr("startOffset", this.value + "%"); + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { startOffset: +this.value }); tip("Label offset: " + this.value + "%"); } function changeRelativeSize() { elSelected.select("textPath").attr("font-size", this.value + "%"); + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { fontSize: +this.value }); tip("Label relative size: " + this.value + "%"); changeText(); } function changeLetterSpacingSize() { elSelected.select("textPath").attr("letter-spacing", this.value + "px"); + if (currentLabelData) Labels.updateLabel(currentLabelData.i, { letterSpacing: +this.value }); tip("Label letter-spacing size: " + this.value + "px"); changeText(); } @@ -379,6 +465,11 @@ function editLabel() { const path = defs.select("#textPath_" + elSelected.attr("id")); path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`); drawControlPointsAndLine(); + // Sync aligned path to data model + if (currentLabelData) { + const pathEl = byId("textPath_" + elSelected.attr("id")); + Labels.updateLabel(currentLabelData.i, { pathPoints: extractPathPoints(pathEl) }); + } } function editLabelLegend() { @@ -395,6 +486,7 @@ function editLabel() { buttons: { Remove: function () { $(this).dialog("close"); + if (currentLabelData) Labels.removeLabel(currentLabelData.i); defs.select("#textPath_" + elSelected.attr("id")).remove(); elSelected.remove(); $("#labelEditor").dialog("close"); From 2379770bfa49cf78ae4472189905e94ca0a319fc Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Fri, 13 Feb 2026 18:12:11 +0100 Subject: [PATCH 06/19] refactor: clean up code formatting and improve timing logic in label generation functions --- src/modules/labels.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/modules/labels.ts b/src/modules/labels.ts index 548645c5b..8f24b8225 100644 --- a/src/modules/labels.ts +++ b/src/modules/labels.ts @@ -48,7 +48,7 @@ class LabelsModule { return existingIds[existingIds.length - 1] + 1; } - generate() : void { + generate(): void { this.clear(); generateStateLabels(); generateBurgLabels(); @@ -153,8 +153,7 @@ class LabelsModule { * @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"); + if (TIME) console.time("generateStateLabels"); const { states } = pack; const labelsModule = window.Labels; @@ -178,8 +177,7 @@ export function generateStateLabels(list?: number[]): void { }); } - if (!TIME) console.timeEnd("generateStateLabels"); - else TIME && console.timeEnd("generateStateLabels"); + if (TIME) console.timeEnd("generateStateLabels"); } /** @@ -200,7 +198,7 @@ export function generateBurgLabels(): void { 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; From 471e865c5e6ce07a1ac6eb0caaee29c1625f04a3 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Fri, 13 Feb 2026 18:12:16 +0100 Subject: [PATCH 07/19] refactor: update import path for findClosestCell utility --- src/utils/label-raycast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/label-raycast.ts b/src/utils/label-raycast.ts index 05e760430..f4f3520c6 100644 --- a/src/utils/label-raycast.ts +++ b/src/utils/label-raycast.ts @@ -1,4 +1,4 @@ -import { findClosestCell } from "./index"; +import { findClosestCell } from "./graphUtils"; export interface Ray { angle: number; From ca6d01f4beff9ae46ef5bf4a2a0b9b8ce4316ab1 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Mon, 16 Feb 2026 19:59:29 +0100 Subject: [PATCH 08/19] feat: synchronize label data model with burg name and position updates --- public/modules/ui/burg-editor.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/public/modules/ui/burg-editor.js b/public/modules/ui/burg-editor.js index 4232d2391..3cdfa3fd1 100644 --- a/public/modules/ui/burg-editor.js +++ b/public/modules/ui/burg-editor.js @@ -118,6 +118,9 @@ function editBurg(id) { const id = +elSelected.attr("data-id"); pack.burgs[id].name = burgName.value; elSelected.text(burgName.value); + // Sync to Labels data model + const labelData = Labels.getBurgLabel(id); + if (labelData) Labels.updateLabel(labelData.i, {text: burgName.value}); } function generateNameRandom() { @@ -382,6 +385,10 @@ function editBurg(id) { burg.y = y; if (burg.capital) pack.states[newState].center = burg.cell; + // Sync position to Labels data model + const labelData = Labels.getBurgLabel(id); + if (labelData) Labels.updateLabel(labelData.i, {x, y}); + if (d3.event.shiftKey === false) toggleRelocateBurg(); } From 3ab40ada5f0abb40d6343706770e41dad434a0f1 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 17 Feb 2026 01:45:48 +0100 Subject: [PATCH 09/19] feat: migrate label data structure from SVG to data model and update version to 1.113.0 --- public/modules/dynamic/auto-update.js | 152 ++++++++++++++++++++++++++ public/modules/io/load.js | 3 +- public/modules/io/save.js | 4 +- public/versioning.js | 2 +- 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index 0b1cd227c..e255930d4 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -1106,4 +1106,156 @@ export function resolveVersionConflicts(mapVersion) { } } + + if (isOlderThan("1.113.0")) { + // v1.113.0 moved labels data from SVG to data model + // Migrate old SVG labels to pack.labels structure + if (!pack.labels || !pack.labels.length) { + pack.labels = []; + let labelId = 0; + + // Migrate state labels + const stateLabelsGroup = document.querySelector("#labels > #states"); + if (stateLabelsGroup) { + stateLabelsGroup.querySelectorAll("text").forEach(textElement => { + const id = textElement.getAttribute("id"); + if (!id || !id.startsWith("stateLabel")) return; + + const stateIdMatch = id.match(/stateLabel(\d+)/); + if (!stateIdMatch) return; + + const stateId = +stateIdMatch[1]; + const state = pack.states[stateId]; + if (!state || state.removed) return; + + const textPath = textElement.querySelector("textPath"); + if (!textPath) return; + + const text = textPath.textContent.trim(); + const fontSizeAttr = textPath.getAttribute("font-size"); + const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100; + + pack.labels.push({ + i: labelId++, + type: "state", + stateId: stateId, + text: text, + fontSize: fontSize + }); + }); + } + + // Migrate burg labels + const burgLabelsGroup = document.querySelector("#burgLabels"); + if (burgLabelsGroup) { + burgLabelsGroup.querySelectorAll("g").forEach(groupElement => { + const group = groupElement.getAttribute("id"); + if (!group) return; + + const dxAttr = groupElement.getAttribute("data-dx"); + const dyAttr = groupElement.getAttribute("data-dy"); + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; + + groupElement.querySelectorAll("text").forEach(textElement => { + const burgId = +textElement.getAttribute("data-id"); + if (!burgId) return; + + const burg = pack.burgs[burgId]; + if (!burg || burg.removed) return; + + const text = textElement.textContent.trim(); + // Use burg coordinates, not SVG text coordinates + // SVG coordinates may be affected by viewbox transforms + const x = burg.x; + const y = burg.y; + + pack.labels.push({ + i: labelId++, + type: "burg", + burgId: burgId, + group: group, + text: text, + x: x, + y: y, + dx: dx, + dy: dy + }); + }); + }); + } + + // Migrate custom labels + const customLabelsGroup = document.querySelector("#labels > #addedLabels"); + if (customLabelsGroup) { + customLabelsGroup.querySelectorAll("text").forEach(textElement => { + const id = textElement.getAttribute("id"); + if (!id) return; + + const group = "custom"; + const textPath = textElement.querySelector("textPath"); + if (!textPath) return; + + const text = textPath.textContent.trim(); + const fontSizeAttr = textPath.getAttribute("font-size"); + const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100; + const letterSpacingAttr = textPath.getAttribute("letter-spacing"); + const letterSpacing = letterSpacingAttr ? parseFloat(letterSpacingAttr) : 0; + const startOffsetAttr = textPath.getAttribute("startOffset"); + const startOffset = startOffsetAttr ? parseFloat(startOffsetAttr) : 50; + const transform = textPath.getAttribute("transform"); + + // Get path points from the referenced path + const href = textPath.getAttribute("href"); + if (!href) return; + + const pathId = href.replace("#", ""); + const pathElement = document.getElementById(pathId); + if (!pathElement) return; + + const d = pathElement.getAttribute("d"); + if (!d) return; + + // Parse path data to extract points (simplified - assumes M and L commands) + const pathPoints = []; + const commands = d.match(/[MLZ][^MLZ]*/g); + if (commands) { + commands.forEach(cmd => { + const type = cmd[0]; + if (type === "M" || type === "L") { + const coords = cmd.slice(1).trim().split(/[\s,]+/).map(Number); + if (coords.length >= 2) { + pathPoints.push([coords[0], coords[1]]); + } + } + }); + } + + if (pathPoints.length > 0) { + pack.labels.push({ + i: labelId++, + type: "custom", + group: group, + text: text, + pathPoints: pathPoints, + startOffset: startOffset, + fontSize: fontSize, + letterSpacing: letterSpacing, + transform: transform || undefined + }); + } + }); + } + + // Clear old SVG labels and redraw from data + if (stateLabelsGroup) stateLabelsGroup.querySelectorAll("*").forEach(el => el.remove()); + if (burgLabelsGroup) burgLabelsGroup.querySelectorAll("text").forEach(el => el.remove()); + + // Regenerate labels from data + if (layerIsOn("toggleLabels")) { + drawStateLabels(); + drawBurgLabels(); + } + } + } } diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 9b401733e..441e25fe0 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -407,6 +407,7 @@ async function parseLoadedData(data, mapVersion) { // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; pack.ice = data[39] ? JSON.parse(data[39]) : []; + pack.labels = data[40] ? JSON.parse(data[40]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -473,7 +474,7 @@ async function parseLoadedData(data, mapVersion) { { // dynamically import and run auto-update script - const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.109.4"); + const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.113.0"); resolveVersionConflicts(mapVersion); } diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 25cd7493c..4d6c40ea1 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -104,6 +104,7 @@ function prepareMapData() { const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); const ice = JSON.stringify(pack.ice); + const labels = JSON.stringify(pack.labels || []); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -158,7 +159,8 @@ function prepareMapData() { cellRoutes, routes, zones, - ice + ice, + labels ].join("\r\n"); return mapData; } diff --git a/public/versioning.js b/public/versioning.js index fd2a67a2d..07993ff8c 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.112.1"; +const VERSION = "1.113.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { From 6ab2c03860f268e66ca0436f51e966eb3deb396a Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 17 Feb 2026 01:45:52 +0100 Subject: [PATCH 10/19] fix: prevent error on rendering removed or neutral states in stateLabelsRenderer --- src/renderers/draw-state-labels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index a09de10d7..e42078c6c 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -79,7 +79,7 @@ const stateLabelsRenderer = (list?: number[]): void => { 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"); + continue; // Calculate pathPoints using raycast algorithm (recalculated on each draw) const offset = getOffsetWidth(state.cells!); From 861db87bff7ec8d4dd1cd43421a5a8dc5b0b87a7 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 17 Feb 2026 01:50:26 +0100 Subject: [PATCH 11/19] refactor: encapsulate label generation functions within LabelsModule --- src/modules/labels.ts | 139 +++++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 71 deletions(-) diff --git a/src/modules/labels.ts b/src/modules/labels.ts index 8f24b8225..4b89d6c97 100644 --- a/src/modules/labels.ts +++ b/src/modules/labels.ts @@ -50,8 +50,8 @@ class LabelsModule { generate(): void { this.clear(); - generateStateLabels(); - generateBurgLabels(); + this.generateStateLabels(); + this.generateBurgLabels(); } getAll(): LabelData[] { @@ -145,78 +145,75 @@ class LabelsModule { clear(): void { pack.labels = []; } -} - -/** - * 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"); - - 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, - }); + + /** + * 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 + */ + generateStateLabels(list?: number[]): void { + if (TIME) console.time("generateStateLabels"); + + const { states } = pack; + + // Remove existing state labels that need regeneration + if (list) { + list.forEach((stateId) => this.removeStateLabel(stateId)); + } else { + this.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; + + this.addStateLabel({ + stateId: state.i, + text: state.name!, + fontSize: 100, + }); + } + + if (TIME) console.timeEnd("generateStateLabels"); + } + + /** + * Generate burg labels data from burgs. + * Populates pack.labels with BurgLabelData for each burg. + */ + generateBurgLabels(): void { + if (TIME) console.time("generateBurgLabels"); + + // Remove existing burg labels + this.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; + + this.addBurgLabel({ + burgId: burg.i, + group, + text: burg.name!, + x: burg.x, + y: burg.y, + dx, + dy, + }); + } + + if (TIME) console.timeEnd("generateBurgLabels"); } - if (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(); From 0d56479e007aa69b983075bf85c2dde0724a2ef3 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 17 Feb 2026 01:51:08 +0100 Subject: [PATCH 12/19] refactor: update import path for label-raycast and improve state label rendering logic --- src/renderers/draw-state-labels.ts | 6 +++--- src/{utils => renderers}/label-raycast.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/{utils => renderers}/label-raycast.ts (98%) diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index e42078c6c..307423bde 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -11,7 +11,7 @@ import { raycast, findBestRayPair, ANGLES, -} from "../utils/label-raycast"; +} from "./label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; @@ -57,8 +57,8 @@ const stateLabelsRenderer = (list?: number[]): void => { // 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) + .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 letterLength = checkExampleLetterLength(); diff --git a/src/utils/label-raycast.ts b/src/renderers/label-raycast.ts similarity index 98% rename from src/utils/label-raycast.ts rename to src/renderers/label-raycast.ts index f4f3520c6..c02680875 100644 --- a/src/utils/label-raycast.ts +++ b/src/renderers/label-raycast.ts @@ -1,4 +1,4 @@ -import { findClosestCell } from "./graphUtils"; +import { findClosestCell } from "../utils/graphUtils"; export interface Ray { angle: number; From c22e6eb0c57dc2d21e7f93c63ec3185b0e532c45 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 17 Feb 2026 02:11:35 +0100 Subject: [PATCH 13/19] chore: update script version numbers to 1.113.0 in index.html --- src/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.html b/src/index.html index 7b3f031d4..9ebb271b2 100644 --- a/src/index.html +++ b/src/index.html @@ -8509,7 +8509,7 @@ - + @@ -8527,12 +8527,12 @@ - + - + @@ -8555,8 +8555,8 @@ - - + + From 32e70496da9477044aee50e3652e2b84cb73a8b4 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Thu, 19 Feb 2026 19:13:11 +0100 Subject: [PATCH 14/19] refactor: replace currentLabelData with direct calls to getLabelData in editLabel function --- public/modules/ui/labels-editor.js | 58 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/public/modules/ui/labels-editor.js b/public/modules/ui/labels-editor.js index 32d1c2c74..647eb7bff 100644 --- a/public/modules/ui/labels-editor.js +++ b/public/modules/ui/labels-editor.js @@ -1,5 +1,4 @@ "use strict"; -let currentLabelData = null; // Helper: extract control points from an SVG path element function extractPathPoints(pathElement) { @@ -61,9 +60,6 @@ function editLabel() { elSelected = d3.select(text).call(d3.drag().on("start", dragLabel)).classed("draggable", true); viewbox.on("touchmove mousemove", showEditorTips); - // Resolve label data from the data model - currentLabelData = getLabelData(text); - $("#labelEditor").dialog({ title: "Edit Label", resizable: false, @@ -136,17 +132,18 @@ function editLabel() { } function updateValues(textPath) { - if (currentLabelData && currentLabelData.type === "custom") { + const labelData = getLabelData(elSelected.node()); + if (labelData && labelData.type === "custom") { // Custom labels: read all values from data model - byId("labelText").value = currentLabelData.text || ""; - byId("labelStartOffset").value = currentLabelData.startOffset || 50; - byId("labelRelativeSize").value = currentLabelData.fontSize || 100; - byId("labelLetterSpacingSize").value = currentLabelData.letterSpacing || 0; + byId("labelText").value = labelData.text || ""; + byId("labelStartOffset").value = labelData.startOffset || 50; + byId("labelRelativeSize").value = labelData.fontSize || 100; + byId("labelLetterSpacingSize").value = labelData.letterSpacing || 0; } else { // State labels and fallback: read from SVG, use data model fontSize if available byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|"); byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset")) || 50; - byId("labelRelativeSize").value = (currentLabelData && currentLabelData.fontSize) || parseFloat(textPath.getAttribute("font-size")) || 100; + byId("labelRelativeSize").value = (labelData && labelData.fontSize) || parseFloat(textPath.getAttribute("font-size")) || 100; let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0; byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize); } @@ -197,7 +194,8 @@ function editLabel() { path.setAttribute("d", d); debug.select("#controlPoints > path").attr("d", d); // Sync path control points back to data model - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { pathPoints: points }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { pathPoints: points }); } function clickControlPoint() { @@ -252,7 +250,8 @@ function editLabel() { const transform = `translate(${dx + x},${dy + y})`; elSelected.attr("transform", transform); debug.select("#controlPoints").attr("transform", transform); - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { transform }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { transform }); }); } @@ -271,8 +270,9 @@ function editLabel() { function changeGroup() { byId(this.value).appendChild(elSelected.node()); - if (currentLabelData && currentLabelData.type === "custom") { - Labels.updateLabel(currentLabelData.i, { group: this.value }); + const labelData = getLabelData(elSelected.node()); + if (labelData && labelData.type === "custom") { + Labels.updateLabel(labelData.i, { group: this.value }); } } @@ -327,8 +327,9 @@ function editLabel() { byId("labelGroupSelect").options.add(new Option(group, group, false, true)); byId(group).appendChild(elSelected.node()); // Update data model group for the moved label - if (currentLabelData && currentLabelData.type === "custom") { - Labels.updateLabel(currentLabelData.i, { group }); + const labelData = getLabelData(elSelected.node()); + if (labelData && labelData.type === "custom") { + Labels.updateLabel(labelData.i, { group }); } toggleNewGroupInput(); @@ -393,7 +394,8 @@ function editLabel() { } else el.innerHTML = `${lines}`; // Update data model - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { text: input }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { text: input }); if (elSelected.attr("id").slice(0, 10) === "stateLabel") tip("Use States Editor to change an actual state name, not just a label", false, "warning"); @@ -401,8 +403,9 @@ function editLabel() { function generateRandomName() { let name = ""; - if (currentLabelData && currentLabelData.type === "state") { - const culture = pack.states[currentLabelData.stateId].culture; + const labelData = getLabelData(elSelected.node()); + if (labelData && labelData.type === "state") { + const culture = pack.states[labelData.stateId].culture; name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture); } else { const box = elSelected.node().getBBox(); @@ -441,20 +444,23 @@ function editLabel() { function changeStartOffset() { elSelected.select("textPath").attr("startOffset", this.value + "%"); - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { startOffset: +this.value }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { startOffset: +this.value }); tip("Label offset: " + this.value + "%"); } function changeRelativeSize() { elSelected.select("textPath").attr("font-size", this.value + "%"); - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { fontSize: +this.value }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { fontSize: +this.value }); tip("Label relative size: " + this.value + "%"); changeText(); } function changeLetterSpacingSize() { elSelected.select("textPath").attr("letter-spacing", this.value + "px"); - if (currentLabelData) Labels.updateLabel(currentLabelData.i, { letterSpacing: +this.value }); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.updateLabel(labelData.i, { letterSpacing: +this.value }); tip("Label letter-spacing size: " + this.value + "px"); changeText(); } @@ -466,9 +472,10 @@ function editLabel() { path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`); drawControlPointsAndLine(); // Sync aligned path to data model - if (currentLabelData) { + const labelData = getLabelData(elSelected.node()); + if (labelData) { const pathEl = byId("textPath_" + elSelected.attr("id")); - Labels.updateLabel(currentLabelData.i, { pathPoints: extractPathPoints(pathEl) }); + Labels.updateLabel(labelData.i, { pathPoints: extractPathPoints(pathEl) }); } } @@ -486,7 +493,8 @@ function editLabel() { buttons: { Remove: function () { $(this).dialog("close"); - if (currentLabelData) Labels.removeLabel(currentLabelData.i); + const labelData = getLabelData(elSelected.node()); + if (labelData) Labels.removeLabel(labelData.i); defs.select("#textPath_" + elSelected.attr("id")).remove(); elSelected.remove(); $("#labelEditor").dialog("close"); From 6d99d8260d71bd62311fc5097690474bd24ec795 Mon Sep 17 00:00:00 2001 From: kruschen Date: Thu, 19 Feb 2026 19:42:18 +0100 Subject: [PATCH 15/19] Fix spelling in label-raycast.ts [Copilot] Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/renderers/label-raycast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderers/label-raycast.ts b/src/renderers/label-raycast.ts index c02680875..342135cf5 100644 --- a/src/renderers/label-raycast.ts +++ b/src/renderers/label-raycast.ts @@ -23,7 +23,7 @@ interface RaycastParams { offset: number; } -// increase step to 15 or 30 to make it faster and more horyzontal +// increase step to 15 or 30 to make it faster and more horizontal // decrease step to 5 to improve accuracy const ANGLE_STEP = 9; export const ANGLES = precalculateAngles(ANGLE_STEP); From 6fa3f786dc384301a3acfefea629a9d0cc1ea7e2 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Thu, 19 Feb 2026 20:07:48 +0100 Subject: [PATCH 16/19] refactor: improve code formatting and organization in labels and renderers --- src/modules/labels.ts | 57 +++++++++++++++--------------- src/renderers/draw-state-labels.ts | 39 +++++++++----------- src/renderers/label-raycast.ts | 6 +--- src/types/PackedGraph.ts | 2 +- 4 files changed, 48 insertions(+), 56 deletions(-) diff --git a/src/modules/labels.ts b/src/modules/labels.ts index 4b89d6c97..a607ee72f 100644 --- a/src/modules/labels.ts +++ b/src/modules/labels.ts @@ -79,15 +79,17 @@ class LabelsModule { } getBurgLabel(burgId: number): BurgLabelData | undefined { - return pack.labels.find( - (l) => l.type === "burg" && l.burgId === burgId, - ) as BurgLabelData | undefined; + return pack.labels.find((l) => l.type === "burg" && l.burgId === burgId) as + | BurgLabelData + | undefined; } - addStateLabel( - data: Omit, - ): StateLabelData { - const label: StateLabelData = { i: this.getNextId(), type: "state", ...data }; + addStateLabel(data: Omit): StateLabelData { + const label: StateLabelData = { + i: this.getNextId(), + type: "state", + ...data, + }; pack.labels.push(label); return label; } @@ -98,10 +100,12 @@ class LabelsModule { return label; } - addCustomLabel( - data: Omit, - ): CustomLabelData { - const label: CustomLabelData = { i: this.getNextId(), type: "custom", ...data }; + addCustomLabel(data: Omit): CustomLabelData { + const label: CustomLabelData = { + i: this.getNextId(), + type: "custom", + ...data, + }; pack.labels.push(label); return label; } @@ -123,8 +127,7 @@ class LabelsModule { removeByGroup(group: string): void { pack.labels = pack.labels.filter( - (l) => - !((l.type === "burg" || l.type === "custom") && l.group === group), + (l) => !((l.type === "burg" || l.type === "custom") && l.group === group), ); } @@ -145,7 +148,7 @@ class LabelsModule { clear(): void { pack.labels = []; } - + /** * Generate state labels data entries for each state. * Only stores essential label data; raycast path calculation happens during rendering. @@ -153,52 +156,52 @@ class LabelsModule { */ generateStateLabels(list?: number[]): void { if (TIME) console.time("generateStateLabels"); - + const { states } = pack; - + // Remove existing state labels that need regeneration if (list) { list.forEach((stateId) => this.removeStateLabel(stateId)); } else { this.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; - + this.addStateLabel({ stateId: state.i, text: state.name!, fontSize: 100, }); } - + if (TIME) console.timeEnd("generateStateLabels"); } - + /** * Generate burg labels data from burgs. * Populates pack.labels with BurgLabelData for each burg. */ generateBurgLabels(): void { if (TIME) console.time("generateBurgLabels"); - + // Remove existing burg labels this.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; - + this.addBurgLabel({ burgId: burg.i, group, @@ -209,11 +212,9 @@ class LabelsModule { dy, }); } - + if (TIME) console.timeEnd("generateBurgLabels"); } - } - window.Labels = new LabelsModule(); diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index 307423bde..7015abaeb 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -1,17 +1,7 @@ import { curveNatural, line, max, select } from "d3"; -import { - findClosestCell, - minmax, - rn, - round, - splitInTwo, -} from "../utils"; import type { StateLabelData } from "../modules/labels"; -import { - raycast, - findBestRayPair, - ANGLES, -} from "./label-raycast"; +import { findClosestCell, minmax, rn, round, splitInTwo } from "../utils"; +import { ANGLES, findBestRayPair, raycast } from "./label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; @@ -57,8 +47,11 @@ const stateLabelsRenderer = (list?: number[]): void => { // 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) + .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 letterLength = checkExampleLetterLength(); @@ -67,7 +60,10 @@ const stateLabelsRenderer = (list?: number[]): void => { // restore labels visibility labels.style("display", layerDisplay); - function drawLabelPath(letterLength: number, labelDataList: StateLabelData[]): void { + function drawLabelPath( + letterLength: number, + labelDataList: StateLabelData[], + ): void { const mode = options.stateLabelsMode || "auto"; const lineGen = line<[number, number]>().curve(curveNatural); @@ -78,8 +74,7 @@ const stateLabelsRenderer = (list?: number[]): void => { for (const labelData of labelDataList) { const state = states[labelData.stateId]; - if (!state.i || state.removed) - continue; + if (!state.i || state.removed) continue; // Calculate pathPoints using raycast algorithm (recalculated on each draw) const offset = getOffsetWidth(state.cells!); @@ -161,16 +156,16 @@ const stateLabelsRenderer = (list?: number[]): void => { textElement.insertAdjacentHTML("afterbegin", spans.join("")); const { width, height } = textElement.getBBox(); - textElement.setAttribute("href", `#textPath_stateLabel${labelData.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( diff --git a/src/renderers/label-raycast.ts b/src/renderers/label-raycast.ts index 342135cf5..41b394142 100644 --- a/src/renderers/label-raycast.ts +++ b/src/renderers/label-raycast.ts @@ -49,11 +49,7 @@ export function raycast({ const stateIds = cells.state; let ray = { length: 0, x: x0, y: y0 }; - for ( - let length = LENGTH_START; - length < LENGTH_MAX; - length += LENGTH_STEP - ) { + for (let length = LENGTH_START; length < LENGTH_MAX; length += LENGTH_STEP) { const [x, y] = [x0 + length * dx, y0 + length * dy]; // offset points are perpendicular to the ray const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index f54d81576..8c1fed100 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,11 +1,11 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; +import type { LabelData } from "../modules/labels"; import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; -import type { LabelData } from "../modules/labels"; import type { Zone } from "../modules/zones-generator"; type TypedArray = From e1740567c6745e21cc0cb35369000e3d53534316 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Sat, 21 Feb 2026 20:37:58 +0100 Subject: [PATCH 17/19] refactor: optimize label rendering by building HTML string for batch updates --- src/renderers/draw-burg-labels.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index 864395599..1a78cfe4b 100644 --- a/src/renderers/draw-burg-labels.ts +++ b/src/renderers/draw-burg-labels.ts @@ -35,22 +35,23 @@ const burgLabelsRenderer = (): void => { const dx = dxAttr ? parseFloat(dxAttr) : 0; const dy = dyAttr ? parseFloat(dyAttr) : 0; + // Build HTML string for all labels in this group + const labelsHTML: string[] = []; 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); + labelsHTML.push( + `${labelData.text}` + ); + } + + // Set all labels at once + const groupNode = labelGroup.node(); + if (groupNode) { + groupNode.innerHTML = labelsHTML.join(""); } } From fd3200739f954bce83fe6d4768c97c3f82c1b84e Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Tue, 24 Feb 2026 19:06:22 +0100 Subject: [PATCH 18/19] apply format --- src/renderers/draw-burg-labels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index 1a78cfe4b..e59f14f64 100644 --- a/src/renderers/draw-burg-labels.ts +++ b/src/renderers/draw-burg-labels.ts @@ -44,7 +44,7 @@ const burgLabelsRenderer = (): void => { } labelsHTML.push( - `${labelData.text}` + `${labelData.text}`, ); } From 3927a762fccf12458ee3b96c9f7e1425a59ce104 Mon Sep 17 00:00:00 2001 From: StempunkDev Date: Fri, 27 Feb 2026 01:23:22 +0100 Subject: [PATCH 19/19] chore: update version to 1.114.0 and adjust related script references + fix migration --- public/modules/dynamic/auto-update.js | 15 ++++++++++----- public/versioning.js | 2 +- src/index.html | 10 +++++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index e255930d4..aaf635396 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -1107,8 +1107,8 @@ export function resolveVersionConflicts(mapVersion) { } - if (isOlderThan("1.113.0")) { - // v1.113.0 moved labels data from SVG to data model + if (isOlderThan("1.114.0")) { + // v1.114.0 moved labels data from SVG to data model // Migrate old SVG labels to pack.labels structure if (!pack.labels || !pack.labels.length) { pack.labels = []; @@ -1206,7 +1206,7 @@ export function resolveVersionConflicts(mapVersion) { const transform = textPath.getAttribute("transform"); // Get path points from the referenced path - const href = textPath.getAttribute("href"); + const href = textPath.getAttribute("xlink:href") || textPath.getAttribute("href"); if (!href) return; const pathId = href.replace("#", ""); @@ -1216,9 +1216,9 @@ export function resolveVersionConflicts(mapVersion) { const d = pathElement.getAttribute("d"); if (!d) return; - // Parse path data to extract points (simplified - assumes M and L commands) + // Parse path data to extract points(M, L and C commands) const pathPoints = []; - const commands = d.match(/[MLZ][^MLZ]*/g); + const commands = d.match(/[MLC][^MLC]*/g); if (commands) { commands.forEach(cmd => { const type = cmd[0]; @@ -1227,6 +1227,11 @@ export function resolveVersionConflicts(mapVersion) { if (coords.length >= 2) { pathPoints.push([coords[0], coords[1]]); } + } else if (type === "C") { + const coords = cmd.slice(1).trim().split(/[\s,]+/).map(Number); + if (coords.length >= 6) { + pathPoints.push([coords[4], coords[5]]); + } } }); } diff --git a/public/versioning.js b/public/versioning.js index d7620449e..ba2b4e45c 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.113.0"; +const VERSION = "1.114.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index e5d2c5f69..878f3947b 100644 --- a/src/index.html +++ b/src/index.html @@ -8506,7 +8506,7 @@ - + @@ -8524,12 +8524,12 @@ - + - + @@ -8551,8 +8551,8 @@ - - + +