feat: integrate label generation into main flow and enhance label data handling

This commit is contained in:
StempunkDev 2026-02-11 22:01:57 +01:00
parent c467f87df5
commit 94b638f3cb
4 changed files with 219 additions and 104 deletions

View file

@ -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();

View file

@ -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<string, BurgLabelData[]>();
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<SVGGElement>(`#${name}`);
// Render each group and update label offsets from SVG attributes
for (const [groupName, labels] of burgLabelsByGroup) {
const labelGroup = burgLabels.select<SVGGElement>(`#${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<SVGGElement>(`#${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 {

View file

@ -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<SVGGElement, unknown>("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<SVGGElement, unknown>("g#labels > g#states");
const pathGroup = select<SVGGElement, unknown>(
"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<SVGGElement, unknown>("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<SVGGElement, unknown>("g#labels > g#states");
const pathGroup = select<SVGGElement, unknown>(
"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 = `<tspan x="0">${text}</tspan>`;
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,