diff --git a/src/layers/renderers/drawLabels.ts b/src/layers/renderers/drawLabels.ts
deleted file mode 100644
index 0b6a8317..00000000
--- a/src/layers/renderers/drawLabels.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import * as d3 from "d3";
-
-import {findCell} from "utils/graphUtils";
-import {isState} from "utils/typeUtils";
-import {drawPath, drawPoint} from "utils/debugUtils";
-
-export function drawLabels() {
- /* global */ const {cells, vertices, features, states, burgs} = pack;
- /* global: findCell, graphWidth, graphHeight */
-
- drawStateLabels(cells, features, states, vertices);
- // drawBurgLabels(burgs);
- // TODO: draw other labels
-
- window.Zoom.invoke();
-}
-
-function drawBurgLabels(burgs: TBurgs) {
- // remove old data
- burgLabels.selectAll("text").remove();
-
- const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[];
-
- // capitals
- const capitals = validBurgs.filter(burg => burg.capital);
- const capitalSize = Number(burgIcons.select("#cities").attr("size")) || 1;
-
- burgLabels
- .select("#cities")
- .selectAll("text")
- .data(capitals)
- .enter()
- .append("text")
- .attr("id", d => "burgLabel" + d.i)
- .attr("data-id", d => d.i)
- .attr("x", d => d.x)
- .attr("y", d => d.y)
- .attr("dy", `${capitalSize * -1.5}px`)
- .text(d => d.name);
-
- // towns
- const towns = validBurgs.filter(burg => !burg.capital);
- const townSize = Number(burgIcons.select("#towns").attr("size")) || 0.5;
-
- burgLabels
- .select("#towns")
- .selectAll("text")
- .data(towns)
- .enter()
- .append("text")
- .attr("id", d => "burgLabel" + d.i)
- .attr("data-id", d => d.i)
- .attr("x", d => d.x)
- .attr("y", d => d.y)
- .attr("dy", `${townSize * -1.5}px`)
- .text(d => d.name);
-}
-
-function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: TStates, vertices: IGraphVertices) {
- console.time("drawStateLabels");
- const lineGen = d3.line().curve(d3.curveBundle.beta(1));
- const mode = options.stateLabelsMode || "auto";
-
- // increase step to increase performarce and make more horyzontal, decrease to increase accuracy
- const STEP = 9;
- const raycast = precalculateAngles(STEP);
-
- const INITIAL_DISTANCE = 5;
- const DISTANCE_STEP = 15;
- const MAX_ITERATIONS = 100;
-
- const labelPaths = getLabelPaths();
-
- function getLabelPaths() {
- const labelPaths: [number, TPoints][] = [];
- const lineGen = d3.line().curve(d3.curveBundle.beta(1));
-
- for (const state of states) {
- if (!isState(state)) continue;
-
- const offset = getOffsetWidth(state.cells);
- const [x0, y0] = state.pole;
-
- const offsetPoints = new Map(
- (offset ? raycast : []).map(({angle, x: x1, y: y1}) => {
- const [x, y] = [x0 + offset * x1, y0 + offset * y1];
- return [angle, {x, y}];
- })
- );
-
- const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => {
- let distanceMin: number;
-
- if (offset) {
- const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!;
- const distance1 = getMaxDistance(state.i, point1, dx, dy);
-
- const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!;
- const distance2 = getMaxDistance(state.i, point2, dx, dy);
- distanceMin = Math.min(distance1, distance2);
- } else {
- distanceMin = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy);
- }
-
- const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy];
- return {angle, distance: distanceMin * modifier, x, y};
- });
-
- const {angle, x, y} = distances.reduce(
- (acc, {angle, distance, x, y}) => {
- if (distance > acc.distance) return {angle, distance, x, y};
- return acc;
- },
- {angle: 0, distance: 0, x: 0, y: 0}
- );
-
- const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180;
- const {x: x2, y: y2} = distances.reduce(
- (acc, {angle, distance, x, y}) => {
- const angleDif = getAnglesDif(angle, oppositeAngle);
- const score = distance * getAngleModifier(angleDif);
- if (score > acc.score) return {angle, score, x, y};
- return acc;
- },
- {angle: 0, score: 0, x: 0, y: 0}
- );
-
- drawPath(lineGen([[x, y], state.pole, [x2, y2]])!, {stroke: "red", strokeWidth: 1});
-
- const pathPoints: TPoints = [];
- labelPaths.push([state.i, pathPoints]);
- }
-
- return labelPaths;
- }
-
- function getMaxDistance(stateId: number, point: {x: number; y: number}, dx: number, dy: number) {
- let distance = INITIAL_DISTANCE;
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const [x, y] = [point.x + distance * dx, point.y + distance * dy];
- const cellId = findCell(x, y);
-
- // const inside = cells.state[cellId] === stateId;
- // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1});
-
- if (cells.state[cellId] !== stateId) break;
- distance += DISTANCE_STEP;
- }
-
- return distance;
- }
-
- console.timeEnd("drawStateLabels");
-}
-
-// point offset to reduce label overlap with state borders
-function getOffsetWidth(cellsNumber: number) {
- if (cellsNumber < 80) return 0;
- if (cellsNumber < 140) return 5;
- if (cellsNumber < 200) return 15;
- if (cellsNumber < 300) return 20;
- if (cellsNumber < 500) return 25;
- return 30;
-}
-
-// difference between two angles in range [0, 180]
-function getAnglesDif(angle1: number, angle2: number) {
- return 180 - Math.abs(Math.abs(angle1 - angle2) - 180);
-}
-
-// score multiplier based on angle difference betwee left and right sides
-function getAngleModifier(angleDif: number) {
- if (angleDif === 0) return 1;
- if (angleDif <= 15) return 0.95;
- if (angleDif <= 30) return 0.9;
- if (angleDif <= 45) return 0.6;
- if (angleDif <= 60) return 0.3;
- if (angleDif <= 90) return 0.1;
- return 0; // >90
-}
-
-function precalculateAngles(step: number) {
- const RAD = Math.PI / 180;
- const angles = [];
-
- for (let angle = 0; angle < 360; angle += step) {
- const x = Math.cos(angle * RAD);
- const y = Math.sin(angle * RAD);
- const angleDif = 90 - Math.abs((angle % 180) - 90);
- const modifier = 1 - angleDif / 120; // [0.25, 1]
- angles.push({angle, modifier, x, y});
- }
-
- return angles;
-}
diff --git a/src/layers/renderers/drawLabels/drawBurgLabels.ts b/src/layers/renderers/drawLabels/drawBurgLabels.ts
new file mode 100644
index 00000000..bf2b5cf0
--- /dev/null
+++ b/src/layers/renderers/drawLabels/drawBurgLabels.ts
@@ -0,0 +1,40 @@
+export function drawBurgLabels(burgs: TBurgs) {
+ // remove old data
+ burgLabels.selectAll("text").remove();
+
+ const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[];
+
+ // capitals
+ const capitals = validBurgs.filter(burg => burg.capital);
+ const capitalSize = Number(burgIcons.select("#cities").attr("size")) || 1;
+
+ burgLabels
+ .select("#cities")
+ .selectAll("text")
+ .data(capitals)
+ .enter()
+ .append("text")
+ .attr("id", d => "burgLabel" + d.i)
+ .attr("data-id", d => d.i)
+ .attr("x", d => d.x)
+ .attr("y", d => d.y)
+ .attr("dy", `${capitalSize * -1.5}px`)
+ .text(d => d.name);
+
+ // towns
+ const towns = validBurgs.filter(burg => !burg.capital);
+ const townSize = Number(burgIcons.select("#towns").attr("size")) || 0.5;
+
+ burgLabels
+ .select("#towns")
+ .selectAll("text")
+ .data(towns)
+ .enter()
+ .append("text")
+ .attr("id", d => "burgLabel" + d.i)
+ .attr("data-id", d => d.i)
+ .attr("x", d => d.x)
+ .attr("y", d => d.y)
+ .attr("dy", `${townSize * -1.5}px`)
+ .text(d => d.name);
+}
diff --git a/src/layers/renderers/drawLabels/drawStateLabels.ts b/src/layers/renderers/drawLabels/drawStateLabels.ts
new file mode 100644
index 00000000..cac6eb5c
--- /dev/null
+++ b/src/layers/renderers/drawLabels/drawStateLabels.ts
@@ -0,0 +1,277 @@
+import * as d3 from "d3";
+
+import {findCell} from "utils/graphUtils";
+import {isState} from "utils/typeUtils";
+import {drawPath, drawPoint, drawPolyline} from "utils/debugUtils";
+import {round, splitInTwo} from "utils/stringUtils";
+import {minmax, rn} from "utils/numberUtils";
+
+// increase step to 15 or 30 to make it faster and more horyzontal, decrease to 5 to improve accuracy
+const STEP = 9;
+const raycast = precalculateAngles(STEP);
+
+const INITIAL_DISTANCE = 5;
+const DISTANCE_STEP = 15;
+const MAX_ITERATIONS = 100;
+
+export function drawStateLabels(cells: IPack["cells"], states: TStates) {
+ /* global: findCell, graphWidth, graphHeight */
+ console.time("drawStateLabels");
+
+ const labelPaths = getLabelPaths(cells.state, states);
+ drawLabelPath(cells.state, states, labelPaths);
+
+ console.timeEnd("drawStateLabels");
+}
+
+function getLabelPaths(stateIds: Uint16Array, states: TStates) {
+ const labelPaths: [number, TPoints][] = [];
+
+ for (const state of states) {
+ if (!isState(state)) continue;
+
+ const offset = getOffsetWidth(state.cells);
+ const [x0, y0] = state.pole;
+
+ const offsetPoints = new Map(
+ (offset ? raycast : []).map(({angle, x: x1, y: y1}) => {
+ const [x, y] = [x0 + offset * x1, y0 + offset * y1];
+ return [angle, {x, y}];
+ })
+ );
+
+ const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => {
+ let distanceMin: number;
+
+ if (offset) {
+ const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!;
+ const distance1 = getMaxDistance(stateIds, state.i, point1, dx, dy);
+
+ const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!;
+ const distance2 = getMaxDistance(stateIds, state.i, point2, dx, dy);
+ distanceMin = Math.min(distance1, distance2);
+ } else {
+ distanceMin = getMaxDistance(stateIds, state.i, {x: x0, y: y0}, dx, dy);
+ }
+
+ const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy];
+ return {angle, distance: distanceMin * modifier, x, y};
+ });
+
+ const {
+ angle,
+ x: x1,
+ y: y1
+ } = distances.reduce(
+ (acc, {angle, distance, x, y}) => {
+ if (distance > acc.distance) return {angle, distance, x, y};
+ return acc;
+ },
+ {angle: 0, distance: 0, x: 0, y: 0}
+ );
+
+ const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180;
+ const {x: x2, y: y2} = distances.reduce(
+ (acc, {angle, distance, x, y}) => {
+ const angleDif = getAnglesDif(angle, oppositeAngle);
+ const score = distance * getAngleModifier(angleDif);
+ if (score > acc.score) return {angle, score, x, y};
+ return acc;
+ },
+ {angle: 0, score: 0, x: 0, y: 0}
+ );
+
+ const pathPoints: TPoints = [[x1, y1], state.pole, [x2, y2]];
+ if (x1 > x2) pathPoints.reverse();
+ labelPaths.push([state.i, pathPoints]);
+ }
+
+ return labelPaths;
+}
+
+function getMaxDistance(stateIds: Uint16Array, stateId: number, point: {x: number; y: number}, dx: number, dy: number) {
+ let distance = INITIAL_DISTANCE;
+
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
+ const [x, y] = [point.x + distance * dx, point.y + distance * dy];
+ const cellId = findCell(x, y);
+
+ // const inside = cells.state[cellId] === stateId;
+ // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1});
+
+ if (stateIds[cellId] !== stateId) break;
+ distance += DISTANCE_STEP;
+ }
+
+ return distance;
+}
+
+function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [number, TPoints][]) {
+ const mode = options.stateLabelsMode || "auto";
+ const lineGen = d3.line().curve(d3.curveBundle.beta(1));
+
+ const textGroup = d3.select("g#labels > g#states");
+ const pathGroup = d3.select("defs > g#deftemp > g#textPaths");
+
+ const example = textGroup.append("text").attr("x", 0).attr("x", 0).text("Average");
+ const letterLength = example.node()!.getComputedTextLength() / 7; // average length of 1 letter
+
+ for (const [stateId, pathPoints] of labelPaths) {
+ const state = states[stateId];
+ if (!isState(state)) throw new Error("State must not be neutral");
+ if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points");
+
+ textGroup.select("#textPath_stateLabel" + stateId).remove();
+ pathGroup.select("#stateLabel" + stateId).remove();
+
+ const textPath = pathGroup
+ .append("path")
+ .attr("d", round(lineGen(pathPoints)!))
+ .attr("id", "textPath_stateLabel" + stateId);
+
+ drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 0.6});
+
+ const pathLength = textPath.node()!.getTotalLength() / letterLength; // path length in letters
+ const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength);
+
+ // prolongate path if it's too short
+ if (pathLength && pathLength < lines[0].length) {
+ const [x1, y1] = pathPoints.at(0)!;
+ const [x2, y2] = pathPoints.at(-1)!;
+ const [dx, dy] = [x2 - x1, y2 - y1];
+
+ const mod = Math.abs((letterLength * lines[0].length) / dx) / 2;
+ pathPoints[0] = [rn(x1 - dx * mod), rn(y1 - dy * mod)];
+ pathPoints[pathPoints.length - 1] = [rn(x2 + dx * mod), rn(y2 + dy * mod)];
+
+ textPath.attr("d", round(lineGen(pathPoints)!));
+ }
+
+ example.attr("font-size", ratio + "%");
+ const top = (lines.length - 1) / -2; // y offset
+ const spans = lines.map((line, index) => {
+ example.text(line);
+ const left = example.node()!.getBBox().width / -2; // x offset
+ return `${line}`;
+ });
+
+ const textElement = textGroup
+ .append("text")
+ .attr("id", "stateLabel" + stateId)
+ .append("textPath")
+ .attr("xlink:href", "#textPath_stateLabel" + stateId)
+ .attr("startOffset", "50%")
+ .attr("font-size", ratio + "%")
+ .node()!;
+
+ textElement.insertAdjacentHTML("afterbegin", spans.join(""));
+ if (mode === "full" || lines.length === 1) continue;
+
+ const isInsideState = checkIfInsideState(textElement, stateIds, stateId);
+ if (isInsideState) continue;
+
+ // replace name to one-liner
+ const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name;
+ example.text(text);
+ const left = example.node()!.getBBox().width / -2; // x offset
+ textElement.innerHTML = `${text}`;
+
+ const correctedRatio = minmax(rn((pathLength / text.length) * 60), 40, 130);
+ textElement.setAttribute("font-size", correctedRatio + "%");
+ }
+
+ example.remove();
+}
+
+// point offset to reduce label overlap with state borders
+function getOffsetWidth(cellsNumber: number) {
+ if (cellsNumber < 80) return 0;
+ if (cellsNumber < 140) return 5;
+ if (cellsNumber < 200) return 15;
+ if (cellsNumber < 300) return 20;
+ if (cellsNumber < 500) return 25;
+ return 30;
+}
+
+// difference between two angles in range [0, 180]
+function getAnglesDif(angle1: number, angle2: number) {
+ return 180 - Math.abs(Math.abs(angle1 - angle2) - 180);
+}
+
+// score multiplier based on angle difference betwee left and right sides
+function getAngleModifier(angleDif: number) {
+ if (angleDif === 0) return 1;
+ if (angleDif <= 15) return 0.95;
+ if (angleDif <= 30) return 0.9;
+ if (angleDif <= 45) return 0.6;
+ if (angleDif <= 60) return 0.3;
+ if (angleDif <= 90) return 0.1;
+ return 0; // >90
+}
+
+function precalculateAngles(step: number) {
+ const RAD = Math.PI / 180;
+ const angles = [];
+
+ for (let angle = 0; angle < 360; angle += step) {
+ const x = Math.cos(angle * RAD);
+ const y = Math.sin(angle * RAD);
+ const angleDif = 90 - Math.abs((angle % 180) - 90);
+ const modifier = 1 - angleDif / 120; // [0.25, 1]
+ angles.push({angle, modifier, x, y});
+ }
+
+ return angles;
+}
+
+function getLinesAndRatio(
+ mode: "auto" | "short" | "full",
+ name: string,
+ fullName: string,
+ pathLength: number
+): [string[], number] {
+ // short name
+ if (mode === "short" || (mode === "auto" && pathLength < name.length)) {
+ const lines = splitInTwo(name);
+ const ratio = pathLength / lines[0].length;
+ return [lines, minmax(rn(ratio * 60), 50, 150)];
+ }
+
+ // full name: one line
+ if (pathLength > fullName.length * 2.5) {
+ const lines = [fullName];
+ const ratio = pathLength / lines[0].length;
+ return [lines, minmax(rn(ratio * 70), 70, 170)];
+ }
+
+ // full name: two lines
+ const lines = splitInTwo(fullName);
+ const ratio = pathLength / lines[0].length;
+ 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, stateIds: Uint16Array, stateId: number) {
+ //textElement.querySelectorAll("tspan").forEach(tspan => (tspan.textContent = "A"));
+
+ const {x, y, width, height} = textElement.getBBox();
+
+ const points: TPoints = [
+ [x, y],
+ [x + width, y],
+ [x + width, y + height],
+ [x, y + height],
+ [x + width / 2, y],
+ [x + width / 2, y + height]
+ ];
+ drawPolyline(points, {stroke: "#333"});
+
+ for (let i = 0, pointsInside = 0; i < points.length && pointsInside < 4; i++) {
+ const isInside = stateIds[findCell(...points[i])] === stateId;
+ if (isInside) pointsInside++;
+ drawPoint(points[i], {color: isInside ? "green" : "red"});
+ if (pointsInside > 3) return true;
+ }
+
+ return true;
+}
diff --git a/src/layers/renderers/drawLabels/index.ts b/src/layers/renderers/drawLabels/index.ts
new file mode 100644
index 00000000..c6c85aaf
--- /dev/null
+++ b/src/layers/renderers/drawLabels/index.ts
@@ -0,0 +1,12 @@
+import {drawBurgLabels} from "./drawBurgLabels";
+import {drawStateLabels} from "./drawStateLabels";
+
+export function drawLabels() {
+ /* global */ const {cells, states, burgs} = pack;
+
+ drawStateLabels(cells, states);
+ drawBurgLabels(burgs);
+ // TODO: draw other labels
+
+ window.Zoom.invoke();
+}