mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-27 01:27:23 +01:00
Split view and data for labels (#2)
* Initial plan * Implement label view/data separation with pack.labels Co-authored-by: StempunkDev <39553418+StempunkDev@users.noreply.github.com> * Update label editor to sync changes with pack.labels Co-authored-by: StempunkDev <39553418+StempunkDev@users.noreply.github.com> * Address code review feedback: optimize filtering and add pathData property Co-authored-by: StempunkDev <39553418+StempunkDev@users.noreply.github.com> * Move label migration code from load.js to auto-update.js Co-authored-by: StempunkDev <39553418+StempunkDev@users.noreply.github.com> * Implement label generation and rendering for states and burgs * Bump version to 1.113.0 * Remove initialization of labels array in generate function --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: StempunkDev <39553418+StempunkDev@users.noreply.github.com>
This commit is contained in:
parent
be82ddb0a4
commit
25d06265f1
12 changed files with 962 additions and 351 deletions
|
|
@ -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") || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,7 @@ async function parseLoadedData(data, mapVersion) {
|
||||||
pack.markers = data[35] ? JSON.parse(data[35]) : [];
|
pack.markers = data[35] ? JSON.parse(data[35]) : [];
|
||||||
pack.routes = data[37] ? JSON.parse(data[37]) : [];
|
pack.routes = data[37] ? JSON.parse(data[37]) : [];
|
||||||
pack.zones = data[38] ? JSON.parse(data[38]) : [];
|
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.biome = Uint8Array.from(data[16].split(","));
|
||||||
pack.cells.burg = Uint16Array.from(data[17].split(","));
|
pack.cells.burg = Uint16Array.from(data[17].split(","));
|
||||||
pack.cells.conf = Uint8Array.from(data[18].split(","));
|
pack.cells.conf = Uint8Array.from(data[18].split(","));
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ function prepareMapData() {
|
||||||
const routes = JSON.stringify(pack.routes);
|
const routes = JSON.stringify(pack.routes);
|
||||||
const zones = JSON.stringify(pack.zones);
|
const zones = JSON.stringify(pack.zones);
|
||||||
const ice = JSON.stringify(pack.ice);
|
const ice = JSON.stringify(pack.ice);
|
||||||
|
const labels = JSON.stringify(pack.labels || []);
|
||||||
|
|
||||||
// store name array only if not the same as default
|
// store name array only if not the same as default
|
||||||
const defaultNB = Names.getNameBases();
|
const defaultNB = Names.getNameBases();
|
||||||
|
|
@ -158,7 +159,8 @@ function prepareMapData() {
|
||||||
cellRoutes,
|
cellRoutes,
|
||||||
routes,
|
routes,
|
||||||
zones,
|
zones,
|
||||||
ice
|
ice,
|
||||||
|
labels
|
||||||
].join("\r\n");
|
].join("\r\n");
|
||||||
return mapData;
|
return mapData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,77 @@
|
||||||
"use strict";
|
"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() {
|
function editLabel() {
|
||||||
if (customization) return;
|
if (customization) return;
|
||||||
closeDialogs();
|
closeDialogs();
|
||||||
|
|
@ -133,6 +206,9 @@ function editLabel() {
|
||||||
const d = round(lineGen(points));
|
const d = round(lineGen(points));
|
||||||
path.setAttribute("d", d);
|
path.setAttribute("d", d);
|
||||||
debug.select("#controlPoints > path").attr("d", d);
|
debug.select("#controlPoints > path").attr("d", d);
|
||||||
|
|
||||||
|
// Sync changes to pack.labels
|
||||||
|
syncLabelToPack(elSelected.attr("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickControlPoint() {
|
function clickControlPoint() {
|
||||||
|
|
@ -188,6 +264,11 @@ function editLabel() {
|
||||||
elSelected.attr("transform", transform);
|
elSelected.attr("transform", transform);
|
||||||
debug.select("#controlPoints").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() {
|
function showGroupSection() {
|
||||||
|
|
@ -205,6 +286,9 @@ function editLabel() {
|
||||||
|
|
||||||
function changeGroup() {
|
function changeGroup() {
|
||||||
byId(this.value).appendChild(elSelected.node());
|
byId(this.value).appendChild(elSelected.node());
|
||||||
|
|
||||||
|
// Sync changes to pack.labels
|
||||||
|
syncLabelToPack(elSelected.attr("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleNewGroupInput() {
|
function toggleNewGroupInput() {
|
||||||
|
|
@ -280,6 +364,10 @@ function editLabel() {
|
||||||
.selectAll("text")
|
.selectAll("text")
|
||||||
.each(function () {
|
.each(function () {
|
||||||
byId("textPath_" + this.id).remove();
|
byId("textPath_" + this.id).remove();
|
||||||
|
|
||||||
|
// Remove from pack.labels
|
||||||
|
removeLabelFromPack(this.id);
|
||||||
|
|
||||||
this.remove();
|
this.remove();
|
||||||
});
|
});
|
||||||
if (!basic) labels.select("#" + group).remove();
|
if (!basic) labels.select("#" + group).remove();
|
||||||
|
|
@ -313,6 +401,9 @@ function editLabel() {
|
||||||
|
|
||||||
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
|
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
|
||||||
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
|
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() {
|
function generateRandomName() {
|
||||||
|
|
@ -359,6 +450,9 @@ function editLabel() {
|
||||||
function changeStartOffset() {
|
function changeStartOffset() {
|
||||||
elSelected.select("textPath").attr("startOffset", this.value + "%");
|
elSelected.select("textPath").attr("startOffset", this.value + "%");
|
||||||
tip("Label offset: " + this.value + "%");
|
tip("Label offset: " + this.value + "%");
|
||||||
|
|
||||||
|
// Sync changes to pack.labels
|
||||||
|
syncLabelToPack(elSelected.attr("id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeRelativeSize() {
|
function changeRelativeSize() {
|
||||||
|
|
@ -395,9 +489,13 @@ function editLabel() {
|
||||||
buttons: {
|
buttons: {
|
||||||
Remove: function () {
|
Remove: function () {
|
||||||
$(this).dialog("close");
|
$(this).dialog("close");
|
||||||
defs.select("#textPath_" + elSelected.attr("id")).remove();
|
const labelId = elSelected.attr("id");
|
||||||
|
defs.select("#textPath_" + labelId).remove();
|
||||||
elSelected.remove();
|
elSelected.remove();
|
||||||
$("#labelEditor").dialog("close");
|
$("#labelEditor").dialog("close");
|
||||||
|
|
||||||
|
// Remove from pack.labels
|
||||||
|
removeLabelFromPack(labelId);
|
||||||
},
|
},
|
||||||
Cancel: function () {
|
Cancel: function () {
|
||||||
$(this).dialog("close");
|
$(this).dialog("close");
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
|
* 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");
|
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,4 @@ import "./states-generator";
|
||||||
import "./zones-generator";
|
import "./zones-generator";
|
||||||
import "./religions-generator";
|
import "./religions-generator";
|
||||||
import "./provinces-generator";
|
import "./provinces-generator";
|
||||||
|
import "./labels-generator";
|
||||||
|
|
|
||||||
429
src/modules/labels-generator.ts
Normal file
429
src/modules/labels-generator.ts
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Burg } from "../modules/burgs-generator";
|
import type { Burg } from "../modules/burgs-generator";
|
||||||
|
import { generateBurgLabelsData } from "../modules/labels-generator";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var drawBurgLabels: () => void;
|
var drawBurgLabels: () => void;
|
||||||
|
|
@ -15,33 +16,39 @@ const burgLabelsRenderer = (): void => {
|
||||||
TIME && console.time("drawBurgLabels");
|
TIME && console.time("drawBurgLabels");
|
||||||
createLabelGroups();
|
createLabelGroups();
|
||||||
|
|
||||||
for (const { name } of options.burgs.groups as BurgGroup[]) {
|
// Clear existing burg labels from pack.labels
|
||||||
const burgsInGroup = pack.burgs.filter(
|
if (!pack.labels) pack.labels = [];
|
||||||
(b) => b.group === name && !b.removed,
|
pack.labels = pack.labels.filter((label) => label.type !== "burg");
|
||||||
);
|
|
||||||
if (!burgsInGroup.length) continue;
|
|
||||||
|
|
||||||
const labelGroup = burgLabels.select<SVGGElement>(`#${name}`);
|
// Generate label data using the generator
|
||||||
|
const generatedLabels = generateBurgLabelsData();
|
||||||
|
|
||||||
|
// Render labels from generated data
|
||||||
|
for (const label of generatedLabels) {
|
||||||
|
const labelGroup = burgLabels.select<SVGGElement>(`#${label.group}`);
|
||||||
if (labelGroup.empty()) continue;
|
if (labelGroup.empty()) continue;
|
||||||
|
|
||||||
const dx = labelGroup.attr("data-dx") || 0;
|
const dx = labelGroup.attr("data-dx") || 0;
|
||||||
const dy = labelGroup.attr("data-dy") || 0;
|
const dy = labelGroup.attr("data-dy") || 0;
|
||||||
|
|
||||||
|
const burg = pack.burgs[label.burgId!];
|
||||||
|
if (!burg || burg.removed) continue;
|
||||||
|
|
||||||
labelGroup
|
labelGroup
|
||||||
.selectAll("text")
|
|
||||||
.data(burgsInGroup)
|
|
||||||
.enter()
|
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
.attr("text-rendering", "optimizeSpeed")
|
||||||
.attr("id", (d) => `burgLabel${d.i}`)
|
.attr("id", label.i)
|
||||||
.attr("data-id", (d) => d.i!)
|
.attr("data-id", label.burgId!)
|
||||||
.attr("x", (d) => d.x)
|
.attr("x", burg.x)
|
||||||
.attr("y", (d) => d.y)
|
.attr("y", burg.y)
|
||||||
.attr("dx", `${dx}em`)
|
.attr("dx", `${dx}em`)
|
||||||
.attr("dy", `${dy}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");
|
TIME && console.timeEnd("drawBurgLabels");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,21 +63,48 @@ const drawBurgLabelRenderer = (burg: Burg): void => {
|
||||||
const dy = labelGroup.attr("data-dy") || 0;
|
const dy = labelGroup.attr("data-dy") || 0;
|
||||||
|
|
||||||
removeBurgLabelRenderer(burg.i!);
|
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
|
labelGroup
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
.attr("text-rendering", "optimizeSpeed")
|
||||||
.attr("id", `burgLabel${burg.i}`)
|
.attr("id", labelData.i)
|
||||||
.attr("data-id", burg.i!)
|
.attr("data-id", burg.i!)
|
||||||
.attr("x", burg.x)
|
.attr("x", burg.x)
|
||||||
.attr("y", burg.y)
|
.attr("y", burg.y)
|
||||||
.attr("dx", `${dx}em`)
|
.attr("dx", `${dx}em`)
|
||||||
.attr("dy", `${dy}em`)
|
.attr("dy", `${dy}em`)
|
||||||
.text(burg.name!);
|
.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 removeBurgLabelRenderer = (burgId: number): void => {
|
||||||
const existingLabel = document.getElementById(`burgLabel${burgId}`);
|
const existingLabel = document.getElementById(`burgLabel${burgId}`);
|
||||||
if (existingLabel) existingLabel.remove();
|
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 {
|
function createLabelGroups(): void {
|
||||||
|
|
|
||||||
196
src/renderers/draw-labels.ts
Normal file
196
src/renderers/draw-labels.ts
Normal file
|
|
@ -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<SVGGElement>(`#${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<SVGGElement>("g#labels > g#states");
|
||||||
|
const pathGroup = defs.select<SVGGElement>("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) =>
|
||||||
|
`<tspan x="0" dy="${index ? 1 : top}em">${lineText}</tspan>`,
|
||||||
|
);
|
||||||
|
textPathElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
||||||
|
} else {
|
||||||
|
textPathElement.innerHTML = `<tspan x="0">${label.name}</tspan>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<SVGGElement>(`g#labels > g#${group}`);
|
||||||
|
if (textGroup.empty()) return;
|
||||||
|
|
||||||
|
const pathGroup = defs.select<SVGGElement>("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) =>
|
||||||
|
`<tspan x="0" dy="${index ? 1 : top}em">${lineText}</tspan>`,
|
||||||
|
);
|
||||||
|
textPathElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
||||||
|
} else {
|
||||||
|
textPathElement.innerHTML = `<tspan x="0">${label.name}</tspan>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.drawLabels = labelsRenderer;
|
||||||
|
window.drawLabel = drawLabelRenderer;
|
||||||
|
window.removeLabel = removeLabelRenderer;
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
import { curveNatural, line, max, select } from "d3";
|
import { curveNatural, line, max, select } from "d3";
|
||||||
import {
|
import {
|
||||||
drawPath,
|
checkIfLabelFitsState,
|
||||||
drawPoint,
|
generateStateLabelsData,
|
||||||
findClosestCell,
|
getLinesAndRatio,
|
||||||
minmax,
|
type StateLabel,
|
||||||
rn,
|
} from "../modules/labels-generator";
|
||||||
round,
|
import { minmax, rn, round } from "../utils";
|
||||||
splitInTwo,
|
|
||||||
} from "../utils";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var drawStateLabels: (list?: number[]) => void;
|
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
|
// list - an optional array of stateIds to regenerate
|
||||||
const stateLabelsRenderer = (list?: number[]): void => {
|
const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
TIME && console.time("drawStateLabels");
|
TIME && console.time("drawStateLabels");
|
||||||
|
|
@ -36,68 +19,35 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
const layerDisplay = labels.style("display");
|
const layerDisplay = labels.style("display");
|
||||||
labels.style("display", null);
|
labels.style("display", null);
|
||||||
|
|
||||||
const { cells, states, features } = pack;
|
const { cells, states } = pack;
|
||||||
const stateIds = cells.state;
|
const stateIds = cells.state;
|
||||||
|
|
||||||
// increase step to 15 or 30 to make it faster and more horyzontal
|
// Initialize pack.labels if needed
|
||||||
// decrease step to 5 to improve accuracy
|
if (!pack.labels) pack.labels = [];
|
||||||
const ANGLE_STEP = 9;
|
|
||||||
const angles = precalculateAngles(ANGLE_STEP);
|
|
||||||
|
|
||||||
const LENGTH_START = 5;
|
// Clear existing state labels from pack.labels if regenerating all
|
||||||
const LENGTH_STEP = 5;
|
if (!list) {
|
||||||
const LENGTH_MAX = 300;
|
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();
|
const letterLength = checkExampleLetterLength();
|
||||||
drawLabelPath(letterLength);
|
const refinedLabels = renderAndRefineLabels(generatedLabels, letterLength);
|
||||||
|
|
||||||
|
// Store refined labels in pack.labels
|
||||||
|
pack.labels.push(...refinedLabels);
|
||||||
|
|
||||||
// restore labels visibility
|
// restore labels visibility
|
||||||
labels.style("display", layerDisplay);
|
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 {
|
function checkExampleLetterLength(): number {
|
||||||
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||||
const testLabel = textGroup
|
const testLabel = textGroup
|
||||||
|
|
@ -112,32 +62,35 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
return letterLength;
|
return letterLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawLabelPath(letterLength: number): void {
|
function renderAndRefineLabels(
|
||||||
|
labels: StateLabel[],
|
||||||
|
letterLength: number,
|
||||||
|
): StateLabel[] {
|
||||||
const mode = options.stateLabelsMode || "auto";
|
const mode = options.stateLabelsMode || "auto";
|
||||||
const lineGen = line<[number, number]>().curve(curveNatural);
|
const lineGen = line<[number, number]>().curve(curveNatural);
|
||||||
|
|
||||||
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||||
const pathGroup = select<SVGGElement, unknown>(
|
const pathGroup = select<SVGGElement, unknown>(
|
||||||
"defs > g#deftemp > g#textPaths",
|
"defs > g#deftemp > g#textPaths",
|
||||||
);
|
);
|
||||||
|
const refinedLabels: typeof labels = [];
|
||||||
|
|
||||||
for (const [stateId, pathPoints] of labelPaths) {
|
for (const label of labels) {
|
||||||
const state = states[stateId];
|
const state = states[label.stateId];
|
||||||
if (!state.i || state.removed)
|
if (!state.i || state.removed) continue;
|
||||||
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();
|
const pathPoints = label.points;
|
||||||
pathGroup.select(`#textPath_stateLabel${stateId}`).remove();
|
if (pathPoints.length < 2) continue;
|
||||||
|
|
||||||
|
textGroup.select(`#${label.i}`).remove();
|
||||||
|
pathGroup.select(`#textPath_${label.i}`).remove();
|
||||||
|
|
||||||
const textPath = pathGroup
|
const textPath = pathGroup
|
||||||
.append("path")
|
.append("path")
|
||||||
.attr("d", round(lineGen(pathPoints) || ""))
|
.attr("d", round(lineGen(pathPoints) || ""))
|
||||||
.attr("id", `textPath_stateLabel${stateId}`);
|
.attr("id", `textPath_${label.i}`);
|
||||||
|
|
||||||
const pathLength =
|
const pathLength =
|
||||||
(textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters
|
(textPath.node() as SVGPathElement).getTotalLength() / letterLength;
|
||||||
const [lines, ratio] = getLinesAndRatio(
|
const [lines, ratio] = getLinesAndRatio(
|
||||||
mode,
|
mode,
|
||||||
state.name!,
|
state.name!,
|
||||||
|
|
@ -165,13 +118,13 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
const textElement = textGroup
|
const textElement = textGroup
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
.attr("text-rendering", "optimizeSpeed")
|
||||||
.attr("id", `stateLabel${stateId}`)
|
.attr("id", label.i)
|
||||||
.append("textPath")
|
.append("textPath")
|
||||||
.attr("startOffset", "50%")
|
.attr("startOffset", "50%")
|
||||||
.attr("font-size", `${ratio}%`)
|
.attr("font-size", `${ratio}%`)
|
||||||
.node() as SVGTextPathElement;
|
.node() as SVGTextPathElement;
|
||||||
|
|
||||||
const top = (lines.length - 1) / -2; // y offset
|
const top = (lines.length - 1) / -2;
|
||||||
const spans = lines.map(
|
const spans = lines.map(
|
||||||
(lineText, index) =>
|
(lineText, index) =>
|
||||||
`<tspan x="0" dy="${index ? 1 : top}em">${lineText}</tspan>`,
|
`<tspan x="0" dy="${index ? 1 : top}em">${lineText}</tspan>`,
|
||||||
|
|
@ -179,258 +132,51 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
||||||
|
|
||||||
const { width, height } = textElement.getBBox();
|
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
|
if (mode !== "full" && lines.length > 1) {
|
||||||
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 angleRad = Math.atan2(y2 - y1, x2 - x1);
|
||||||
|
|
||||||
const isInsideState = checkIfInsideState(
|
const isInsideState = checkIfLabelFitsState(
|
||||||
textElement,
|
textElement,
|
||||||
angleRad,
|
angleRad,
|
||||||
width / 2,
|
width / 2,
|
||||||
height / 2,
|
height / 2,
|
||||||
stateIds,
|
stateIds,
|
||||||
stateId,
|
label.stateId,
|
||||||
);
|
);
|
||||||
if (isInsideState) continue;
|
|
||||||
|
|
||||||
// replace name to one-liner
|
if (!isInsideState) {
|
||||||
const text =
|
const text =
|
||||||
pathLength > state.fullName!.length * 1.8
|
pathLength > state.fullName!.length * 1.8
|
||||||
? state.fullName!
|
? state.fullName!
|
||||||
: state.name!;
|
: state.name!;
|
||||||
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
|
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
|
||||||
|
|
||||||
const correctedRatio = minmax(
|
const correctedRatio = minmax(
|
||||||
rn((pathLength / text.length) * 50),
|
rn((pathLength / text.length) * 50),
|
||||||
50,
|
50,
|
||||||
130,
|
130,
|
||||||
);
|
);
|
||||||
textElement.setAttribute("font-size", `${correctedRatio}%`);
|
textElement.setAttribute("font-size", `${correctedRatio}%`);
|
||||||
}
|
finalName = text;
|
||||||
}
|
finalRatio = 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]];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refinedLabels.push({
|
||||||
|
...label,
|
||||||
|
name: finalName,
|
||||||
|
fontSize: finalRatio,
|
||||||
|
points: pathPoints,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestPair!;
|
return refinedLabels;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TIME && console.timeEnd("drawStateLabels");
|
TIME && console.timeEnd("drawStateLabels");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import "./draw-emblems";
|
||||||
import "./draw-features";
|
import "./draw-features";
|
||||||
import "./draw-heightmap";
|
import "./draw-heightmap";
|
||||||
import "./draw-ice";
|
import "./draw-ice";
|
||||||
|
import "./draw-labels";
|
||||||
import "./draw-markers";
|
import "./draw-markers";
|
||||||
import "./draw-military";
|
import "./draw-military";
|
||||||
import "./draw-relief-icons";
|
import "./draw-relief-icons";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { River } from "../modules/river-generator";
|
||||||
import type { Route } from "../modules/routes-generator";
|
import type { Route } from "../modules/routes-generator";
|
||||||
import type { State } from "../modules/states-generator";
|
import type { State } from "../modules/states-generator";
|
||||||
import type { Zone } from "../modules/zones-generator";
|
import type { Zone } from "../modules/zones-generator";
|
||||||
|
import type { Label } from "../renderers/draw-labels";
|
||||||
|
|
||||||
type TypedArray =
|
type TypedArray =
|
||||||
| Uint8Array
|
| Uint8Array
|
||||||
|
|
@ -63,4 +64,5 @@ export interface PackedGraph {
|
||||||
markers: any[];
|
markers: any[];
|
||||||
ice: any[];
|
ice: any[];
|
||||||
provinces: Province[];
|
provinces: Province[];
|
||||||
|
labels: Label[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue