mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-04-04 14:37:24 +02:00
refactor: clean up label-related code and introduce raycasting utilities
This commit is contained in:
parent
7bf33b6e65
commit
dac231f914
3 changed files with 193 additions and 175 deletions
|
|
@ -2,8 +2,6 @@ declare global {
|
||||||
var Labels: LabelsModule;
|
var Labels: LabelsModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Types ---
|
|
||||||
|
|
||||||
export interface StateLabelData {
|
export interface StateLabelData {
|
||||||
i: number;
|
i: number;
|
||||||
type: "state";
|
type: "state";
|
||||||
|
|
@ -42,8 +40,6 @@ export interface CustomLabelData {
|
||||||
|
|
||||||
export type LabelData = StateLabelData | BurgLabelData | CustomLabelData;
|
export type LabelData = StateLabelData | BurgLabelData | CustomLabelData;
|
||||||
|
|
||||||
// --- Implementation ---
|
|
||||||
|
|
||||||
class LabelsModule {
|
class LabelsModule {
|
||||||
private getNextId(): number {
|
private getNextId(): number {
|
||||||
const labels = pack.labels;
|
const labels = pack.labels;
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,17 @@ import {
|
||||||
round,
|
round,
|
||||||
splitInTwo,
|
splitInTwo,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
import {
|
||||||
|
Ray,
|
||||||
|
raycast,
|
||||||
|
findBestRayPair,
|
||||||
|
ANGLES
|
||||||
|
} from "../utils/label-raycast";
|
||||||
|
|
||||||
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][];
|
type PathPoints = [number, number][];
|
||||||
|
|
||||||
// list - an optional array of stateIds to regenerate
|
// list - an optional array of stateIds to regenerate
|
||||||
|
|
@ -36,18 +29,9 @@ 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
|
|
||||||
// decrease step to 5 to improve accuracy
|
|
||||||
const ANGLE_STEP = 9;
|
|
||||||
const angles = precalculateAngles(ANGLE_STEP);
|
|
||||||
|
|
||||||
const LENGTH_START = 5;
|
|
||||||
const LENGTH_STEP = 5;
|
|
||||||
const LENGTH_MAX = 300;
|
|
||||||
|
|
||||||
const labelPaths = getLabelPaths();
|
const labelPaths = getLabelPaths();
|
||||||
const letterLength = checkExampleLetterLength();
|
const letterLength = checkExampleLetterLength();
|
||||||
drawLabelPath(letterLength);
|
drawLabelPath(letterLength);
|
||||||
|
|
@ -66,7 +50,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
const maxLakeSize = state.cells! / 20;
|
const maxLakeSize = state.cells! / 20;
|
||||||
const [x0, y0] = state.pole!;
|
const [x0, y0] = state.pole!;
|
||||||
|
|
||||||
const rays: Ray[] = angles.map(({ angle, dx, dy }) => {
|
const rays: Ray[] = ANGLES.map(({ angle, dx, dy }) => {
|
||||||
const { length, x, y } = raycast({
|
const { length, x, y } = raycast({
|
||||||
stateId: state.i,
|
stateId: state.i,
|
||||||
x0,
|
x0,
|
||||||
|
|
@ -219,153 +203,6 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
return 10;
|
return 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
function precalculateAngles(step: number): AngleData[] {
|
|
||||||
const angles: AngleData[] = [];
|
|
||||||
const RAD = Math.PI / 180;
|
|
||||||
|
|
||||||
for (let angle = 0; angle < 360; angle += step) {
|
|
||||||
const dx = Math.cos(angle * RAD);
|
|
||||||
const dy = Math.sin(angle * RAD);
|
|
||||||
angles.push({ angle, dx, dy });
|
|
||||||
}
|
|
||||||
|
|
||||||
return angles;
|
|
||||||
}
|
|
||||||
|
|
||||||
function raycast({
|
|
||||||
stateId,
|
|
||||||
x0,
|
|
||||||
y0,
|
|
||||||
dx,
|
|
||||||
dy,
|
|
||||||
maxLakeSize,
|
|
||||||
offset,
|
|
||||||
}: {
|
|
||||||
stateId: number;
|
|
||||||
x0: number;
|
|
||||||
y0: number;
|
|
||||||
dx: number;
|
|
||||||
dy: number;
|
|
||||||
maxLakeSize: number;
|
|
||||||
offset: number;
|
|
||||||
}): { length: number; x: number; y: number } {
|
|
||||||
let ray = { length: 0, x: x0, y: y0 };
|
|
||||||
|
|
||||||
for (
|
|
||||||
let length = LENGTH_START;
|
|
||||||
length < LENGTH_MAX;
|
|
||||||
length += LENGTH_STEP
|
|
||||||
) {
|
|
||||||
const [x, y] = [x0 + length * dx, y0 + length * dy];
|
|
||||||
// offset points are perpendicular to the ray
|
|
||||||
const offset1: [number, number] = [x + -dy * offset, y + dx * offset];
|
|
||||||
const offset2: [number, number] = [x + dy * offset, y + -dx * offset];
|
|
||||||
|
|
||||||
if (DEBUG.stateLabels) {
|
|
||||||
drawPoint([x, y], {
|
|
||||||
color: isInsideState(x, y) ? "blue" : "red",
|
|
||||||
radius: 0.8,
|
|
||||||
});
|
|
||||||
drawPoint(offset1, {
|
|
||||||
color: isInsideState(...offset1) ? "blue" : "red",
|
|
||||||
radius: 0.4,
|
|
||||||
});
|
|
||||||
drawPoint(offset2, {
|
|
||||||
color: isInsideState(...offset2) ? "blue" : "red",
|
|
||||||
radius: 0.4,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const inState =
|
|
||||||
isInsideState(x, y) &&
|
|
||||||
isInsideState(...offset1) &&
|
|
||||||
isInsideState(...offset2);
|
|
||||||
if (!inState) break;
|
|
||||||
ray = { length, x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
return ray;
|
|
||||||
|
|
||||||
function isInsideState(x: number, y: number): boolean {
|
|
||||||
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
|
|
||||||
const cellId = findClosestCell(x, y, undefined, pack) as number;
|
|
||||||
|
|
||||||
const feature = features[cells.f[cellId]];
|
|
||||||
if (feature.type === "lake")
|
|
||||||
return isInnerLake(feature) || isSmallLake(feature);
|
|
||||||
|
|
||||||
return stateIds[cellId] === stateId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInnerLake(feature: { shoreline: number[] }): boolean {
|
|
||||||
return feature.shoreline.every((cellId) => stateIds[cellId] === stateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSmallLake(feature: { cells: number }): boolean {
|
|
||||||
return feature.cells <= maxLakeSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findBestRayPair(rays: Ray[]): [Ray, Ray] {
|
|
||||||
let bestPair: [Ray, Ray] | null = null;
|
|
||||||
let bestScore = -Infinity;
|
|
||||||
|
|
||||||
for (let i = 0; i < rays.length; i++) {
|
|
||||||
const score1 = rays[i].length * scoreRayAngle(rays[i].angle);
|
|
||||||
|
|
||||||
for (let j = i + 1; j < rays.length; j++) {
|
|
||||||
const score2 = rays[j].length * scoreRayAngle(rays[j].angle);
|
|
||||||
const pairScore =
|
|
||||||
(score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle);
|
|
||||||
|
|
||||||
if (pairScore > bestScore) {
|
|
||||||
bestScore = pairScore;
|
|
||||||
bestPair = [rays[i], rays[j]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestPair!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreRayAngle(angle: number): number {
|
|
||||||
const normalizedAngle = Math.abs(angle % 180); // [0, 180]
|
|
||||||
const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1]
|
|
||||||
|
|
||||||
if (horizontality === 1) return 1; // Best: horizontal
|
|
||||||
if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted
|
|
||||||
if (horizontality >= 0.5) return 0.6; // Good: moderate slant
|
|
||||||
if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted
|
|
||||||
if (horizontality >= 0.15) return 0.2; // Poor: almost vertical
|
|
||||||
return 0.1; // Very poor: almost vertical
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreCurvature(angle1: number, angle2: number): number {
|
|
||||||
const delta = getAngleDelta(angle1, angle2);
|
|
||||||
const similarity = evaluateArc(angle1, angle2);
|
|
||||||
|
|
||||||
if (delta === 180) return 1; // straight line: best
|
|
||||||
if (delta < 90) return 0; // acute: not allowed
|
|
||||||
if (delta < 120) return 0.6 * similarity;
|
|
||||||
if (delta < 140) return 0.7 * similarity;
|
|
||||||
if (delta < 160) return 0.8 * similarity;
|
|
||||||
|
|
||||||
return similarity;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAngleDelta(angle1: number, angle2: number): number {
|
|
||||||
let delta = Math.abs(angle1 - angle2) % 360;
|
|
||||||
if (delta > 180) delta = 360 - delta; // [0, 180]
|
|
||||||
return delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
// compute arc similarity towards x-axis
|
|
||||||
function evaluateArc(angle1: number, angle2: number): number {
|
|
||||||
const proximity1 = Math.abs((angle1 % 180) - 90);
|
|
||||||
const proximity2 = Math.abs((angle2 % 180) - 90);
|
|
||||||
return 1 - Math.abs(proximity1 - proximity2) / 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLinesAndRatio(
|
function getLinesAndRatio(
|
||||||
mode: string,
|
mode: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
|
||||||
185
src/utils/label-raycast.ts
Normal file
185
src/utils/label-raycast.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { findClosestCell } from "./index";
|
||||||
|
|
||||||
|
export interface Ray {
|
||||||
|
angle: number;
|
||||||
|
length: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AngleData {
|
||||||
|
angle: number;
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RaycastParams {
|
||||||
|
stateId: number;
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
maxLakeSize: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// increase step to 15 or 30 to make it faster and more horyzontal
|
||||||
|
// decrease step to 5 to improve accuracy
|
||||||
|
const ANGLE_STEP = 9;
|
||||||
|
export const ANGLES = precalculateAngles(ANGLE_STEP);
|
||||||
|
|
||||||
|
const LENGTH_START = 5;
|
||||||
|
const LENGTH_STEP = 5;
|
||||||
|
const LENGTH_MAX = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast a ray from a point in a given direction until it exits a state.
|
||||||
|
* Checks both the ray point and offset points perpendicular to it.
|
||||||
|
*/
|
||||||
|
export function raycast({
|
||||||
|
stateId,
|
||||||
|
x0,
|
||||||
|
y0,
|
||||||
|
dx,
|
||||||
|
dy,
|
||||||
|
maxLakeSize,
|
||||||
|
offset,
|
||||||
|
}: RaycastParams): { length: number; x: number; y: number } {
|
||||||
|
const { cells, features } = pack;
|
||||||
|
const stateIds = cells.state;
|
||||||
|
let ray = { length: 0, x: x0, y: y0 };
|
||||||
|
|
||||||
|
for (
|
||||||
|
let length = LENGTH_START;
|
||||||
|
length < LENGTH_MAX;
|
||||||
|
length += LENGTH_STEP
|
||||||
|
) {
|
||||||
|
const [x, y] = [x0 + length * dx, y0 + length * dy];
|
||||||
|
// offset points are perpendicular to the ray
|
||||||
|
const offset1: [number, number] = [x + -dy * offset, y + dx * offset];
|
||||||
|
const offset2: [number, number] = [x + dy * offset, y + -dx * offset];
|
||||||
|
|
||||||
|
const inState =
|
||||||
|
isInsideState(x, y, stateId) &&
|
||||||
|
isInsideState(...offset1, stateId) &&
|
||||||
|
isInsideState(...offset2, stateId);
|
||||||
|
if (!inState) break;
|
||||||
|
ray = { length, x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ray;
|
||||||
|
|
||||||
|
function isInsideState(x: number, y: number, stateId: number): boolean {
|
||||||
|
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
|
||||||
|
const cellId = findClosestCell(x, y, undefined, pack) as number;
|
||||||
|
|
||||||
|
const feature = features[cells.f[cellId]];
|
||||||
|
if (feature.type === "lake")
|
||||||
|
return isInnerLake(feature) || isSmallLake(feature);
|
||||||
|
|
||||||
|
return stateIds[cellId] === stateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInnerLake(feature: { shoreline: number[] }): boolean {
|
||||||
|
return feature.shoreline.every((cellId) => stateIds[cellId] === stateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSmallLake(feature: { cells: number }): boolean {
|
||||||
|
return feature.cells <= maxLakeSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a ray angle based on how horizontal it is.
|
||||||
|
* Horizontal rays (0° or 180°) are preferred for label placement.
|
||||||
|
*/
|
||||||
|
export function scoreRayAngle(angle: number): number {
|
||||||
|
const normalizedAngle = Math.abs(angle % 180); // [0, 180]
|
||||||
|
const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1]
|
||||||
|
|
||||||
|
if (horizontality === 1) return 1; // Best: horizontal
|
||||||
|
if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted
|
||||||
|
if (horizontality >= 0.5) return 0.6; // Good: moderate slant
|
||||||
|
if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted
|
||||||
|
if (horizontality >= 0.15) return 0.2; // Poor: almost vertical
|
||||||
|
return 0.1; // Very poor: almost vertical
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the angle delta between two angles (0-180 degrees).
|
||||||
|
*/
|
||||||
|
export function getAngleDelta(angle1: number, angle2: number): number {
|
||||||
|
let delta = Math.abs(angle1 - angle2) % 360;
|
||||||
|
if (delta > 180) delta = 360 - delta; // [0, 180]
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate how similar the arc between two angles is.
|
||||||
|
* Computes proximity of both angles towards the x-axis.
|
||||||
|
*/
|
||||||
|
export function evaluateArc(angle1: number, angle2: number): number {
|
||||||
|
const proximity1 = Math.abs((angle1 % 180) - 90);
|
||||||
|
const proximity2 = Math.abs((angle2 % 180) - 90);
|
||||||
|
return 1 - Math.abs(proximity1 - proximity2) / 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a ray pair based on the delta angle between them and their arc similarity.
|
||||||
|
* Penalizes acute angles (<90°), favors straight lines (180°).
|
||||||
|
*/
|
||||||
|
export function scoreCurvature(angle1: number, angle2: number): number {
|
||||||
|
const delta = getAngleDelta(angle1, angle2);
|
||||||
|
const similarity = evaluateArc(angle1, angle2);
|
||||||
|
|
||||||
|
if (delta === 180) return 1; // straight line: best
|
||||||
|
if (delta < 90) return 0; // acute: not allowed
|
||||||
|
if (delta < 120) return 0.6 * similarity;
|
||||||
|
if (delta < 140) return 0.7 * similarity;
|
||||||
|
if (delta < 160) return 0.8 * similarity;
|
||||||
|
|
||||||
|
return similarity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precompute angles and their vector components for raycast directions.
|
||||||
|
* Used to sample rays around a point at regular angular intervals.
|
||||||
|
*/
|
||||||
|
function precalculateAngles(step: number): AngleData[] {
|
||||||
|
const angles: AngleData[] = [];
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
for (let angle = 0; angle < 360; angle += step) {
|
||||||
|
const dx = Math.cos(angle * RAD);
|
||||||
|
const dy = Math.sin(angle * RAD);
|
||||||
|
angles.push({ angle, dx, dy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best pair of rays for label placement along a curved path.
|
||||||
|
* Prefers horizontal rays and well-separated angles.
|
||||||
|
*/
|
||||||
|
export function findBestRayPair(rays: Ray[]): [Ray, Ray] {
|
||||||
|
let bestPair: [Ray, Ray] | null = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < rays.length; i++) {
|
||||||
|
const score1 = rays[i].length * scoreRayAngle(rays[i].angle);
|
||||||
|
|
||||||
|
for (let j = i + 1; j < rays.length; j++) {
|
||||||
|
const score2 = rays[j].length * scoreRayAngle(rays[j].angle);
|
||||||
|
const pairScore =
|
||||||
|
(score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle);
|
||||||
|
|
||||||
|
if (pairScore > bestScore) {
|
||||||
|
bestScore = pairScore;
|
||||||
|
bestPair = [rays[i], rays[j]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPair!;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue