diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index 0b1cd227..75c86c98 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -1106,4 +1106,105 @@ export function resolveVersionConflicts(mapVersion) { } } + + if (isOlderThan("1.113.0")) { + // v1.113 separates label data from view layer by introducing pack.labels array + // Migrate labels from SVG to pack.labels for backward compatibility + if (!pack.labels || pack.labels.length === 0) { + pack.labels = []; + + // Extract burg labels from SVG + burgLabels.selectAll("text").each(function() { + const textEl = d3.select(this); + const burgId = +textEl.attr("data-id"); + const id = textEl.attr("id"); + const group = this.parentNode.id; + + if (id && burgId !== undefined) { + pack.labels.push({ + i: id, + type: "burg", + name: textEl.text(), + group: group, + burgId: burgId + }); + } + }); + + // Extract state labels from SVG + labels.select("g#states").selectAll("text").each(function() { + const textEl = d3.select(this); + const id = textEl.attr("id"); + const stateId = id ? +id.replace("stateLabel", "") : null; + + if (id && stateId !== null) { + const textPathEl = textEl.select("textPath"); + const pathId = textPathEl.attr("href")?.replace("#textPath_", ""); + const path = pathId ? defs.select(`#textPath_${id}`) : null; + + let pathData; + if (path && !path.empty()) { + pathData = path.attr("d"); + } + + const lines = []; + textPathEl.selectAll("tspan").each(function() { + lines.push(d3.select(this).text()); + }); + + pack.labels.push({ + i: id, + type: "state", + name: lines.join("|"), + stateId: stateId, + pathData: pathData, + startOffset: parseFloat(textPathEl.attr("startOffset")) || 50, + fontSize: parseFloat(textPathEl.attr("font-size")) || 100, + letterSpacing: parseFloat(textPathEl.attr("letter-spacing")) || 0, + transform: textEl.attr("transform") || "" + }); + } + }); + + // Extract custom labels from other groups + labels.selectAll(":scope > g").each(function() { + const groupId = this.id; + if (groupId === "states" || groupId === "burgLabels") return; + + d3.select(this).selectAll("text").each(function() { + const textEl = d3.select(this); + const id = textEl.attr("id"); + + if (id) { + const textPathEl = textEl.select("textPath"); + const pathId = textPathEl.attr("href")?.replace("#textPath_", ""); + const path = pathId ? defs.select(`#textPath_${id}`) : null; + + let pathData; + if (path && !path.empty()) { + pathData = path.attr("d"); + } + + const lines = []; + textPathEl.selectAll("tspan").each(function() { + lines.push(d3.select(this).text()); + }); + + pack.labels.push({ + i: id, + type: "custom", + name: lines.join("|"), + group: groupId, + pathData: pathData, + startOffset: parseFloat(textPathEl.attr("startOffset")) || 50, + fontSize: parseFloat(textPathEl.attr("font-size")) || 100, + letterSpacing: parseFloat(textPathEl.attr("letter-spacing")) || 0, + transform: textEl.attr("transform") || "" + }); + } + }); + }); + } + } + } diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 9b401733..d668da08 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -392,6 +392,7 @@ async function parseLoadedData(data, mapVersion) { pack.markers = data[35] ? JSON.parse(data[35]) : []; pack.routes = data[37] ? JSON.parse(data[37]) : []; pack.zones = data[38] ? JSON.parse(data[38]) : []; + pack.labels = data[40] ? JSON.parse(data[40]) : []; pack.cells.biome = Uint8Array.from(data[16].split(",")); pack.cells.burg = Uint16Array.from(data[17].split(",")); pack.cells.conf = Uint8Array.from(data[18].split(",")); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 25cd7493..4d6c40ea 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/modules/ui/labels-editor.js b/public/modules/ui/labels-editor.js index 8c47ec99..859352e5 100644 --- a/public/modules/ui/labels-editor.js +++ b/public/modules/ui/labels-editor.js @@ -1,4 +1,77 @@ "use strict"; + +// Helper function to sync label data with pack.labels +function syncLabelToPack(labelId) { + if (!pack.labels) pack.labels = []; + + const textEl = document.getElementById(labelId); + if (!textEl) return; + + const group = textEl.parentNode.id; + const isBurgLabel = labelId.startsWith("burgLabel"); + const isStateLabel = labelId.startsWith("stateLabel"); + + // Remove existing entry + const existingIndex = pack.labels.findIndex(l => l.i === labelId); + + // Gather label data + const labelData = { + i: labelId, + type: isBurgLabel ? "burg" : isStateLabel ? "state" : "custom" + }; + + if (isBurgLabel) { + const burgId = +textEl.getAttribute("data-id"); + labelData.name = textEl.textContent; + labelData.group = group; + labelData.burgId = burgId; + } else { + // State or custom label with textPath + const textPathEl = textEl.querySelector("textPath"); + if (textPathEl) { + const lines = []; + textPathEl.querySelectorAll("tspan").forEach(tspan => { + lines.push(tspan.textContent); + }); + labelData.name = lines.join("|"); + labelData.startOffset = parseFloat(textPathEl.getAttribute("startOffset")) || 50; + labelData.fontSize = parseFloat(textPathEl.getAttribute("font-size")) || 100; + labelData.letterSpacing = parseFloat(textPathEl.getAttribute("letter-spacing")) || 0; + labelData.transform = textEl.getAttribute("transform") || ""; + + if (isStateLabel) { + const stateId = +labelId.replace("stateLabel", ""); + labelData.stateId = stateId; + } else { + labelData.group = group; + } + + // Extract path points + const pathId = `textPath_${labelId}`; + const pathEl = document.getElementById(pathId); + if (pathEl) { + const pathData = pathEl.getAttribute("d"); + // Store the path data - ideally we'd parse it into points, but for now store as-is + // This will be improved when we implement more sophisticated label editing + labelData.pathData = pathData; + } + } + } + + // Update or add to pack.labels + if (existingIndex >= 0) { + pack.labels[existingIndex] = labelData; + } else { + pack.labels.push(labelData); + } +} + +// Helper function to remove label from pack.labels +function removeLabelFromPack(labelId) { + if (!pack.labels) return; + pack.labels = pack.labels.filter(l => l.i !== labelId); +} + function editLabel() { if (customization) return; closeDialogs(); @@ -133,6 +206,9 @@ function editLabel() { const d = round(lineGen(points)); path.setAttribute("d", d); debug.select("#controlPoints > path").attr("d", d); + + // Sync changes to pack.labels + syncLabelToPack(elSelected.attr("id")); } function clickControlPoint() { @@ -188,6 +264,11 @@ function editLabel() { elSelected.attr("transform", transform); debug.select("#controlPoints").attr("transform", transform); }); + + d3.event.on("end", function () { + // Sync changes to pack.labels after drag ends + syncLabelToPack(elSelected.attr("id")); + }); } function showGroupSection() { @@ -205,6 +286,9 @@ function editLabel() { function changeGroup() { byId(this.value).appendChild(elSelected.node()); + + // Sync changes to pack.labels + syncLabelToPack(elSelected.attr("id")); } function toggleNewGroupInput() { @@ -280,6 +364,10 @@ function editLabel() { .selectAll("text") .each(function () { byId("textPath_" + this.id).remove(); + + // Remove from pack.labels + removeLabelFromPack(this.id); + this.remove(); }); if (!basic) labels.select("#" + group).remove(); @@ -313,6 +401,9 @@ function editLabel() { if (elSelected.attr("id").slice(0, 10) === "stateLabel") tip("Use States Editor to change an actual state name, not just a label", false, "warning"); + + // Sync changes to pack.labels + syncLabelToPack(elSelected.attr("id")); } function generateRandomName() { @@ -359,6 +450,9 @@ function editLabel() { function changeStartOffset() { elSelected.select("textPath").attr("startOffset", this.value + "%"); tip("Label offset: " + this.value + "%"); + + // Sync changes to pack.labels + syncLabelToPack(elSelected.attr("id")); } function changeRelativeSize() { @@ -395,9 +489,13 @@ function editLabel() { buttons: { Remove: function () { $(this).dialog("close"); - defs.select("#textPath_" + elSelected.attr("id")).remove(); + const labelId = elSelected.attr("id"); + defs.select("#textPath_" + labelId).remove(); elSelected.remove(); $("#labelEditor").dialog("close"); + + // Remove from pack.labels + removeLabelFromPack(labelId); }, Cancel: function () { $(this).dialog("close"); diff --git a/public/versioning.js b/public/versioning.js index fd2a67a2..07993ff8 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"); { diff --git a/src/modules/index.ts b/src/modules/index.ts index a9ebf2b8..16503c36 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -13,3 +13,4 @@ import "./states-generator"; import "./zones-generator"; import "./religions-generator"; import "./provinces-generator"; +import "./labels-generator"; diff --git a/src/modules/labels-generator.ts b/src/modules/labels-generator.ts new file mode 100644 index 00000000..a197854b --- /dev/null +++ b/src/modules/labels-generator.ts @@ -0,0 +1,429 @@ +import { max } from "d3"; +import { + drawPath, + drawPoint, + findClosestCell, + minmax, + rn, + splitInTwo, +} from "../utils"; + +// Define specific label types +export interface StateLabel { + i: string; + type: "state"; + name: string; + stateId: number; + points: [number, number][]; + startOffset: number; + fontSize: number; + letterSpacing: number; + transform: string; +} + +export interface BurgLabel { + i: string; + type: "burg"; + name: string; + group: string; + burgId: number; +} + +export type Label = StateLabel | BurgLabel | CustomLabel; + +export interface CustomLabel { + i: string; + type: "custom"; + name: string; + group?: string; + points?: [number, number][]; + pathData?: string; + startOffset?: number; + fontSize?: number; + letterSpacing?: number; + transform?: string; +} + +declare global { + var generateStateLabels: (stateIds?: number[]) => StateLabel[]; + var generateBurgLabels: () => BurgLabel[]; +} + +interface Ray { + angle: number; + length: number; + x: number; + y: number; +} + +interface AngleData { + angle: number; + dx: number; + dy: number; +} + +type PathPoints = [number, number][]; + +// Constants for raycasting +const ANGLE_STEP = 9; // increase to 15 or 30 to make it faster and more horizontal; decrease to 5 to improve accuracy +const LENGTH_START = 5; +const LENGTH_STEP = 5; +const LENGTH_MAX = 300; + +/** + * Generate label data for state labels + * @param stateIds - Optional array of specific state IDs to generate labels for + * @returns Array of state label data objects + */ +function generateStateLabelsData(stateIds?: number[]): StateLabel[] { + TIME && console.time("generateStateLabels"); + + const { cells, states, features } = pack; + const cellStateIds = cells.state; + const angles = precalculateAngles(ANGLE_STEP); + const labels: StateLabel[] = []; + + for (const state of states) { + if (!state.i || state.removed || state.lock) continue; + if (stateIds && !stateIds.includes(state.i)) continue; + + const offset = getOffsetWidth(state.cells!); + const maxLakeSize = state.cells! / 20; + const [x0, y0] = state.pole!; + + // Generate rays in all directions from state pole + const rays: Ray[] = angles.map(({ angle, dx, dy }) => { + const { length, x, y } = raycast({ + stateId: state.i, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, + cellStateIds, + features, + cells, + }); + return { angle, length, x, y }; + }); + + const [ray1, ray2] = findBestRayPair(rays); + + const pathPoints: PathPoints = [ + [ray1.x, ray1.y], + state.pole!, + [ray2.x, ray2.y], + ]; + if (ray1.x > ray2.x) pathPoints.reverse(); + + if (DEBUG.stateLabels) { + drawPoint(state.pole!, { color: "black", radius: 1 }); + drawPath(pathPoints, { color: "black", width: 0.2 }); + } + + // Create label data object + labels.push({ + i: `stateLabel${state.i}`, + type: "state", + name: state.name!, // Will be updated with formatting later + stateId: state.i, + points: pathPoints, + startOffset: 50, + fontSize: 100, + letterSpacing: 0, + transform: "", + }); + } + + TIME && console.timeEnd("generateStateLabels"); + return labels; +} + +/** + * Generate label data for burg labels + * @returns Array of burg label data objects + */ +function generateBurgLabelsData(): BurgLabel[] { + TIME && console.time("generateBurgLabels"); + + const labels: BurgLabel[] = []; + const burgGroups = options.burgs.groups as { name: string; order: number }[]; + + for (const { name } of burgGroups) { + const burgsInGroup = pack.burgs.filter( + (b) => b.group === name && !b.removed, + ); + + for (const burg of burgsInGroup) { + labels.push({ + i: `burgLabel${burg.i}`, + type: "burg", + name: burg.name!, + group: name, + burgId: burg.i!, + }); + } + } + + TIME && console.timeEnd("generateBurgLabels"); + return labels; +} + +/** + * Precalculate angle data for raycasting + */ +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; +} + +/** + * Cast a ray from state pole to find label path endpoints + */ +function raycast({ + stateId, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, + cellStateIds, + features, + cells, +}: { + stateId: number; + x0: number; + y0: number; + dx: number; + dy: number; + maxLakeSize: number; + offset: number; + cellStateIds: number[]; + features: any[]; + cells: any; +}): { 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 cellStateIds[cellId] === stateId; + } + + function isInnerLake(feature: { shoreline: number[] }): boolean { + return feature.shoreline.every( + (cellId) => cellStateIds[cellId] === stateId, + ); + } + + function isSmallLake(feature: { cells: number }): boolean { + return feature.cells <= maxLakeSize; + } +} + +/** + * Find the best pair of rays for label placement + */ +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!; +} + +/** + * Score ray based on its angle (prefer horizontal) + */ +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 +} + +/** + * Score the curvature between two rays + */ +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; +} + +/** + * Get the delta between two angles + */ +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; +} + +/** + * Get offset width based on state size + */ +function getOffsetWidth(cellsNumber: number): number { + if (cellsNumber < 40) return 0; + if (cellsNumber < 200) return 5; + return 10; +} + +/** + * Calculate lines and font ratio for state labels + */ +export function getLinesAndRatio( + mode: string, + name: string, + fullName: string, + pathLength: number, +): [string[], number] { + if (mode === "short") return getShortOneLine(); + if (pathLength > fullName.length * 2) return getFullOneLine(); + return getFullTwoLines(); + + function getShortOneLine(): [string[], number] { + const ratio = pathLength / name.length; + return [[name], minmax(rn(ratio * 60), 50, 150)]; + } + + function getFullOneLine(): [string[], number] { + const ratio = pathLength / fullName.length; + return [[fullName], minmax(rn(ratio * 70), 70, 170)]; + } + + function getFullTwoLines(): [string[], number] { + const lines = splitInTwo(fullName); + const longestLineLength = max(lines.map((line) => line.length)) || 0; + const ratio = pathLength / longestLineLength; + return [lines, minmax(rn(ratio * 60), 70, 150)]; + } +} + +/** + * Check whether multi-lined label is mostly inside the state + */ +export function checkIfLabelFitsState( + textElement: SVGTextPathElement, + angleRad: number, + halfwidth: number, + halfheight: number, + stateIds: number[], + stateId: number, +): boolean { + const bbox = textElement.getBBox(); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; + + const points: [number, number][] = [ + [-halfwidth, -halfheight], + [+halfwidth, -halfheight], + [+halfwidth, halfheight], + [-halfwidth, halfheight], + [0, halfheight], + [0, -halfheight], + ]; + + const sin = Math.sin(angleRad); + const cos = Math.cos(angleRad); + const rotatedPoints = points.map(([x, y]): [number, number] => [ + cx + x * cos - y * sin, + cy + x * sin + y * cos, + ]); + + let pointsInside = 0; + for (const [x, y] of rotatedPoints) { + const isInside = + stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; + if (isInside) pointsInside++; + if (pointsInside > 4) return true; + } + + return false; +} + +// Expose module functions globally +window.generateStateLabels = generateStateLabelsData; +window.generateBurgLabels = generateBurgLabelsData; + +export { generateStateLabelsData, generateBurgLabelsData }; diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index 5dc6cc71..dbd57e30 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 { generateBurgLabelsData } from "../modules/labels-generator"; declare global { var drawBurgLabels: () => void; @@ -15,33 +16,39 @@ 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; + // Clear existing burg labels from pack.labels + if (!pack.labels) pack.labels = []; + pack.labels = pack.labels.filter((label) => label.type !== "burg"); - const labelGroup = burgLabels.select(`#${name}`); + // Generate label data using the generator + const generatedLabels = generateBurgLabelsData(); + + // Render labels from generated data + for (const label of generatedLabels) { + const labelGroup = burgLabels.select(`#${label.group}`); if (labelGroup.empty()) continue; const dx = labelGroup.attr("data-dx") || 0; const dy = labelGroup.attr("data-dy") || 0; + const burg = pack.burgs[label.burgId!]; + if (!burg || burg.removed) continue; + 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("id", label.i) + .attr("data-id", label.burgId!) + .attr("x", burg.x) + .attr("y", burg.y) .attr("dx", `${dx}em`) .attr("dy", `${dy}em`) - .text((d) => d.name!); + .text(label.name); } + // Store labels in pack.labels + pack.labels.push(...generatedLabels); + TIME && console.timeEnd("drawBurgLabels"); }; @@ -56,21 +63,48 @@ const drawBurgLabelRenderer = (burg: Burg): void => { const dy = labelGroup.attr("data-dy") || 0; removeBurgLabelRenderer(burg.i!); + + // Create label data + const labelData = { + i: `burgLabel${burg.i}`, + type: "burg" as const, + name: burg.name!, + group: burg.group!, + burgId: burg.i!, + }; + + // Render label labelGroup .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("id", `burgLabel${burg.i}`) + .attr("id", labelData.i) .attr("data-id", burg.i!) .attr("x", burg.x) .attr("y", burg.y) .attr("dx", `${dx}em`) .attr("dy", `${dy}em`) .text(burg.name!); + + // Update pack.labels + if (!pack.labels) pack.labels = []; + const existingIndex = pack.labels.findIndex((l) => l.i === labelData.i); + + if (existingIndex >= 0) { + pack.labels[existingIndex] = labelData; + } else { + pack.labels.push(labelData); + } }; const removeBurgLabelRenderer = (burgId: number): void => { const existingLabel = document.getElementById(`burgLabel${burgId}`); if (existingLabel) existingLabel.remove(); + + // Remove from pack.labels + if (pack.labels) { + const labelId = `burgLabel${burgId}`; + pack.labels = pack.labels.filter((l) => l.i !== labelId); + } }; function createLabelGroups(): void { diff --git a/src/renderers/draw-labels.ts b/src/renderers/draw-labels.ts new file mode 100644 index 00000000..aa17f2a5 --- /dev/null +++ b/src/renderers/draw-labels.ts @@ -0,0 +1,196 @@ +import { curveNatural, line } from "d3"; +import type { Label } from "../modules/labels-generator"; + +declare global { + var drawLabels: () => void; + var drawLabel: (label: Label) => void; + var removeLabel: (labelId: string) => void; +} + +export type { Label } from "../modules/labels-generator"; + +// Main label renderer +const labelsRenderer = (): void => { + TIME && console.time("drawLabels"); + + // Render all labels from pack.labels + if (pack.labels && pack.labels.length > 0) { + pack.labels.forEach((label) => { + drawLabelRenderer(label); + }); + } + + TIME && console.timeEnd("drawLabels"); +}; + +// Single label renderer +const drawLabelRenderer = (label: Label): void => { + if (label.type === "burg") { + drawBurgLabelFromData(label); + } else if (label.type === "state") { + drawStateLabelFromData(label); + } else if (label.type === "custom") { + drawCustomLabelFromData(label); + } +}; + +// Remove a label by its ID +const removeLabelRenderer = (labelId: string): void => { + const existingLabel = document.getElementById(labelId); + if (existingLabel) existingLabel.remove(); + + // Remove associated textPath if it exists + const textPath = document.getElementById(`textPath_${labelId}`); + if (textPath) textPath.remove(); +}; + +// Render burg label from label data +function drawBurgLabelFromData(label: Label): void { + if (label.type !== "burg") return; + + const burg = pack.burgs[label.burgId]; + if (!burg || burg.removed) return; + + const group = label.group || burg.group || "town"; + const labelGroup = burgLabels.select(`#${group}`); + if (labelGroup.empty()) return; + + const dx = labelGroup.attr("data-dx") || 0; + const dy = labelGroup.attr("data-dy") || 0; + + removeLabelRenderer(label.i); + labelGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", label.i) + .attr("data-id", label.burgId) + .attr("x", burg.x) + .attr("y", burg.y) + .attr("dx", `${dx}em`) + .attr("dy", `${dy}em`) + .text(label.name); +} + +// Render state label from label data +function drawStateLabelFromData(label: Label): void { + if (label.type !== "state") return; + if (!label.points || label.points.length < 2) return; + + const state = pack.states[label.stateId]; + if (!state || state.removed) return; + + const textGroup = labels.select("g#labels > g#states"); + const pathGroup = defs.select("g#deftemp > g#textPaths"); + + removeLabelRenderer(label.i); + + // Create the path for the text + const lineGen = line<[number, number]>().curve(curveNatural); + const pathData = lineGen(label.points); + if (!pathData) return; + + pathGroup + .append("path") + .attr("d", pathData) + .attr("id", `textPath_${label.i}`); + + const textElement = textGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", label.i); + + if (label.transform) { + textElement.attr("transform", label.transform); + } + + const textPathElement = textElement + .append("textPath") + .attr("startOffset", `${label.startOffset || 50}%`) + .attr("href", `#textPath_${label.i}`) + .node() as SVGTextPathElement; + + if (label.fontSize) { + textPathElement.setAttribute("font-size", `${label.fontSize}%`); + } + + if (label.letterSpacing) { + textPathElement.setAttribute("letter-spacing", `${label.letterSpacing}px`); + } + + // Parse multi-line labels + const lines = label.name.split("|"); + if (lines.length > 1) { + const top = (lines.length - 1) / -2; + const spans = lines.map( + (lineText, index) => + `${lineText}`, + ); + textPathElement.insertAdjacentHTML("afterbegin", spans.join("")); + } else { + textPathElement.innerHTML = `${label.name}`; + } +} + +// Render custom label from label data +function drawCustomLabelFromData(label: Label): void { + if (label.type !== "custom") return; + if (!label.points || label.points.length < 2) return; + + const group = label.group || "addedLabels"; + const textGroup = labels.select(`g#labels > g#${group}`); + if (textGroup.empty()) return; + + const pathGroup = defs.select("g#deftemp > g#textPaths"); + + removeLabelRenderer(label.i); + + // Create the path for the text + const lineGen = line<[number, number]>().curve(curveNatural); + const pathData = lineGen(label.points); + if (!pathData) return; + + pathGroup + .append("path") + .attr("d", pathData) + .attr("id", `textPath_${label.i}`); + + const textElement = textGroup + .append("text") + .attr("text-rendering", "optimizeSpeed") + .attr("id", label.i); + + if (label.transform) { + textElement.attr("transform", label.transform); + } + + const textPathElement = textElement + .append("textPath") + .attr("startOffset", `${label.startOffset || 50}%`) + .attr("href", `#textPath_${label.i}`) + .node() as SVGTextPathElement; + + if (label.fontSize) { + textPathElement.setAttribute("font-size", `${label.fontSize}%`); + } + + if (label.letterSpacing) { + textPathElement.setAttribute("letter-spacing", `${label.letterSpacing}px`); + } + + // Parse multi-line labels + const lines = label.name.split("|"); + if (lines.length > 1) { + const top = (lines.length - 1) / -2; + const spans = lines.map( + (lineText, index) => + `${lineText}`, + ); + textPathElement.insertAdjacentHTML("afterbegin", spans.join("")); + } else { + textPathElement.innerHTML = `${label.name}`; + } +} + +window.drawLabels = labelsRenderer; +window.drawLabel = drawLabelRenderer; +window.removeLabel = removeLabelRenderer; diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index 24528d45..817c27d8 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -1,33 +1,16 @@ import { curveNatural, line, max, select } from "d3"; import { - drawPath, - drawPoint, - findClosestCell, - minmax, - rn, - round, - splitInTwo, -} from "../utils"; + checkIfLabelFitsState, + generateStateLabelsData, + getLinesAndRatio, + type StateLabel, +} from "../modules/labels-generator"; +import { minmax, rn, round } from "../utils"; 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 const stateLabelsRenderer = (list?: number[]): void => { TIME && console.time("drawStateLabels"); @@ -36,68 +19,35 @@ 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); + // Initialize pack.labels if needed + if (!pack.labels) pack.labels = []; - const LENGTH_START = 5; - const LENGTH_STEP = 5; - const LENGTH_MAX = 300; + // Clear existing state labels from pack.labels if regenerating all + if (!list) { + pack.labels = pack.labels.filter((label) => label.type !== "state"); + } else { + // Collect label IDs to remove + const labelsToRemove = list.map((stateId) => `stateLabel${stateId}`); + // Clear specific state labels in a single filter operation + pack.labels = pack.labels.filter((l) => !labelsToRemove.includes(l.i)); + } - const labelPaths = getLabelPaths(); + // Generate label data using the generator + const generatedLabels = generateStateLabelsData(list); + + // Render and refine labels const letterLength = checkExampleLetterLength(); - drawLabelPath(letterLength); + const refinedLabels = renderAndRefineLabels(generatedLabels, letterLength); + + // Store refined labels in pack.labels + pack.labels.push(...refinedLabels); // restore labels visibility labels.style("display", layerDisplay); - function getLabelPaths(): [number, PathPoints][] { - const labelPaths: [number, PathPoints][] = []; - - for (const state of states) { - if (!state.i || state.removed || state.lock) continue; - if (list && !list.includes(state.i)) continue; - - const offset = getOffsetWidth(state.cells!); - const maxLakeSize = state.cells! / 20; - const [x0, y0] = state.pole!; - - const rays: Ray[] = angles.map(({ angle, dx, dy }) => { - const { length, x, y } = raycast({ - stateId: state.i, - x0, - y0, - dx, - dy, - maxLakeSize, - offset, - }); - return { angle, length, x, y }; - }); - const [ray1, ray2] = findBestRayPair(rays); - - const pathPoints: PathPoints = [ - [ray1.x, ray1.y], - state.pole!, - [ray2.x, ray2.y], - ]; - if (ray1.x > ray2.x) pathPoints.reverse(); - - if (DEBUG.stateLabels) { - drawPoint(state.pole!, { color: "black", radius: 1 }); - drawPath(pathPoints, { color: "black", width: 0.2 }); - } - - labelPaths.push([state.i, pathPoints]); - } - - return labelPaths; - } - function checkExampleLetterLength(): number { const textGroup = select("g#labels > g#states"); const testLabel = textGroup @@ -112,32 +62,35 @@ const stateLabelsRenderer = (list?: number[]): void => { return letterLength; } - function drawLabelPath(letterLength: number): void { + function renderAndRefineLabels( + labels: StateLabel[], + letterLength: number, + ): StateLabel[] { 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", ); + const refinedLabels: typeof labels = []; - 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"); + for (const label of labels) { + const state = states[label.stateId]; + if (!state.i || state.removed) continue; - textGroup.select(`#stateLabel${stateId}`).remove(); - pathGroup.select(`#textPath_stateLabel${stateId}`).remove(); + const pathPoints = label.points; + if (pathPoints.length < 2) continue; + + textGroup.select(`#${label.i}`).remove(); + pathGroup.select(`#textPath_${label.i}`).remove(); const textPath = pathGroup .append("path") .attr("d", round(lineGen(pathPoints) || "")) - .attr("id", `textPath_stateLabel${stateId}`); + .attr("id", `textPath_${label.i}`); const pathLength = - (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters + (textPath.node() as SVGPathElement).getTotalLength() / letterLength; const [lines, ratio] = getLinesAndRatio( mode, state.name!, @@ -165,13 +118,13 @@ const stateLabelsRenderer = (list?: number[]): void => { const textElement = textGroup .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("id", `stateLabel${stateId}`) + .attr("id", label.i) .append("textPath") .attr("startOffset", "50%") .attr("font-size", `${ratio}%`) .node() as SVGTextPathElement; - const top = (lines.length - 1) / -2; // y offset + const top = (lines.length - 1) / -2; const spans = lines.map( (lineText, index) => `${lineText}`, @@ -179,258 +132,51 @@ 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_${label.i}`); - if (mode === "full" || lines.length === 1) continue; + let finalName = lines.join("|"); + let finalRatio = ratio; - // check if label fits state boundaries. If no, replace it with short name - const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; - const angleRad = Math.atan2(y2 - y1, x2 - x1); + if (mode !== "full" && lines.length > 1) { + const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; + const angleRad = Math.atan2(y2 - y1, x2 - x1); - const isInsideState = checkIfInsideState( - textElement, - angleRad, - width / 2, - height / 2, - stateIds, - stateId, - ); - if (isInsideState) continue; + const isInsideState = checkIfLabelFitsState( + textElement, + angleRad, + width / 2, + height / 2, + stateIds, + label.stateId, + ); - // replace name to one-liner - const text = - pathLength > state.fullName!.length * 1.8 - ? state.fullName! - : state.name!; - textElement.innerHTML = `${text}`; + if (!isInsideState) { + const text = + pathLength > state.fullName!.length * 1.8 + ? state.fullName! + : state.name!; + textElement.innerHTML = `${text}`; - const correctedRatio = minmax( - rn((pathLength / text.length) * 50), - 50, - 130, - ); - textElement.setAttribute("font-size", `${correctedRatio}%`); - } - } - - function getOffsetWidth(cellsNumber: number): number { - if (cellsNumber < 40) return 0; - if (cellsNumber < 200) return 5; - return 10; - } - - function 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]]; + const correctedRatio = minmax( + rn((pathLength / text.length) * 50), + 50, + 130, + ); + textElement.setAttribute("font-size", `${correctedRatio}%`); + finalName = text; + finalRatio = correctedRatio; } } + + refinedLabels.push({ + ...label, + name: finalName, + fontSize: finalRatio, + points: pathPoints, + }); } - 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, - fullName: string, - pathLength: number, - ): [string[], number] { - if (mode === "short") return getShortOneLine(); - if (pathLength > fullName.length * 2) return getFullOneLine(); - return getFullTwoLines(); - - function getShortOneLine(): [string[], number] { - const ratio = pathLength / name.length; - return [[name], minmax(rn(ratio * 60), 50, 150)]; - } - - function getFullOneLine(): [string[], number] { - const ratio = pathLength / fullName.length; - return [[fullName], minmax(rn(ratio * 70), 70, 170)]; - } - - function getFullTwoLines(): [string[], number] { - const lines = splitInTwo(fullName); - const longestLineLength = max(lines.map((line) => line.length)) || 0; - const ratio = pathLength / longestLineLength; - return [lines, minmax(rn(ratio * 60), 70, 150)]; - } - } - - // check whether multi-lined label is mostly inside the state. If no, replace it with short name label - function checkIfInsideState( - textElement: SVGTextPathElement, - angleRad: number, - halfwidth: number, - halfheight: number, - stateIds: number[], - stateId: number, - ): boolean { - const bbox = textElement.getBBox(); - const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; - - const points: [number, number][] = [ - [-halfwidth, -halfheight], - [+halfwidth, -halfheight], - [+halfwidth, halfheight], - [-halfwidth, halfheight], - [0, halfheight], - [0, -halfheight], - ]; - - const sin = Math.sin(angleRad); - const cos = Math.cos(angleRad); - const rotatedPoints = points.map(([x, y]): [number, number] => [ - cx + x * cos - y * sin, - cy + x * sin + y * cos, - ]); - - let pointsInside = 0; - for (const [x, y] of rotatedPoints) { - const isInside = - stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; - if (isInside) pointsInside++; - if (pointsInside > 4) return true; - } - - return false; + return refinedLabels; } TIME && console.timeEnd("drawStateLabels"); diff --git a/src/renderers/index.ts b/src/renderers/index.ts index 5ea6e502..dcbfaa16 100644 --- a/src/renderers/index.ts +++ b/src/renderers/index.ts @@ -5,6 +5,7 @@ import "./draw-emblems"; import "./draw-features"; import "./draw-heightmap"; import "./draw-ice"; +import "./draw-labels"; import "./draw-markers"; import "./draw-military"; import "./draw-relief-icons"; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index b8749f0a..6c97106c 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -6,6 +6,7 @@ import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; import type { Zone } from "../modules/zones-generator"; +import type { Label } from "../renderers/draw-labels"; type TypedArray = | Uint8Array @@ -63,4 +64,5 @@ export interface PackedGraph { markers: any[]; ice: any[]; provinces: Province[]; + labels: Label[]; }