Phase 1: Core hex engine, Leaflet overlay, terrain painting UI

- core/: Pure TS hex engine (axial coords, hex grid, terrain types,
  edge connectivity with constraint solver, HexMap state model)
- src/map/: Leaflet L.CRS.Simple map init, Canvas-based hex overlay
  layer (L.GridLayer), click/edge interaction detection
- src/ui/: Sidebar with toolbar (Select/Paint/Feature modes),
  terrain picker, hex inspector, map settings (hex size, grid, opacity)
- pipeline/: Tile pyramid generator (sharp, from source image)
- tests/: 32 passing tests for coords, hex-grid, edge-connectivity
- Uses Kiepenkerl tiles (symlinked) for development

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 10:32:52 +00:00
parent 5a19864fb5
commit f302932ea8
20 changed files with 1942 additions and 0 deletions

151
core/coords.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { AxialCoord, PixelCoord, HexGeometry } from './types.js';
import { HexEdge, EDGE_DIRECTIONS, ALL_EDGES } from './types.js';
const SQRT3 = Math.sqrt(3);
/** Convert axial coords to pixel center (flat-top hex) */
export function axialToPixel(coord: AxialCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): PixelCoord {
return {
x: origin.x + size * (3 / 2) * coord.q,
y: origin.y + size * (SQRT3 / 2 * coord.q + SQRT3 * coord.r),
};
}
/** Convert pixel position to fractional axial coords (flat-top hex) */
export function pixelToAxial(pixel: PixelCoord, size: number, origin: PixelCoord = { x: 0, y: 0 }): AxialCoord {
const px = pixel.x - origin.x;
const py = pixel.y - origin.y;
const q = (2 / 3) * px / size;
const r = (-1 / 3 * px + SQRT3 / 3 * py) / size;
return axialRound({ q, r });
}
/** Round fractional axial coords to nearest hex */
export function axialRound(coord: AxialCoord): AxialCoord {
const s = -coord.q - coord.r;
let rq = Math.round(coord.q);
let rr = Math.round(coord.r);
const rs = Math.round(s);
const qDiff = Math.abs(rq - coord.q);
const rDiff = Math.abs(rr - coord.r);
const sDiff = Math.abs(rs - s);
if (qDiff > rDiff && qDiff > sDiff) {
rq = -rr - rs;
} else if (rDiff > sDiff) {
rr = -rq - rs;
}
return { q: rq, r: rr };
}
/** Get the neighbor of a hex in a given direction */
export function getNeighbor(coord: AxialCoord, edge: HexEdge): AxialCoord {
const dir = EDGE_DIRECTIONS[edge];
return { q: coord.q + dir.q, r: coord.r + dir.r };
}
/** Get all 6 neighbors */
export function getNeighbors(coord: AxialCoord): AxialCoord[] {
return ALL_EDGES.map(edge => getNeighbor(coord, edge));
}
/** Axial distance between two hexes */
export function axialDistance(a: AxialCoord, b: AxialCoord): number {
const dq = a.q - b.q;
const dr = a.r - b.r;
return (Math.abs(dq) + Math.abs(dq + dr) + Math.abs(dr)) / 2;
}
/** Create a unique string key for a hex coordinate */
export function coordKey(coord: AxialCoord): string {
return `${coord.q},${coord.r}`;
}
/** Parse a coordinate key back to AxialCoord */
export function parseCoordKey(key: string): AxialCoord {
const [q, r] = key.split(',').map(Number);
return { q, r };
}
/** Compute the 6 vertices of a flat-top hex */
export function hexVertices(cx: number, cy: number, size: number): PixelCoord[] {
const vertices: PixelCoord[] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 180) * (60 * i);
vertices.push({
x: cx + size * Math.cos(angle),
y: cy + size * Math.sin(angle),
});
}
return vertices;
}
/**
* Compute the 6 edge midpoints, indexed by HexEdge enum.
*
* Flat-top vertices are at 0°,60°,120°,180°,240°,300° (screen coords, y+ down).
* Raw midpoints between consecutive vertices sit at 30°,90°,150°,210°,270°,330°.
* EDGE_DIRECTIONS point at -30°(NE),30°(E),90°(SE),150°(SW),210°(W),270°(NW).
* So raw midpoint index i maps to HexEdge (i+1)%6.
* We reorder so result[HexEdge] gives the correct midpoint.
*/
export function hexEdgeMidpoints(vertices: PixelCoord[]): PixelCoord[] {
// Raw midpoints between consecutive vertex pairs
const raw: PixelCoord[] = [];
for (let i = 0; i < 6; i++) {
const next = (i + 1) % 6;
raw.push({
x: (vertices[i].x + vertices[next].x) / 2,
y: (vertices[i].y + vertices[next].y) / 2,
});
}
// Reorder: HexEdge j = raw[(j + 5) % 6]
// NE(0)=raw[5], E(1)=raw[0], SE(2)=raw[1], SW(3)=raw[2], W(4)=raw[3], NW(5)=raw[4]
const reordered: PixelCoord[] = [];
for (let j = 0; j < 6; j++) {
reordered.push(raw[(j + 5) % 6]);
}
return reordered;
}
/** Compute full hex geometry */
export function computeHexGeometry(cx: number, cy: number, size: number): HexGeometry {
const vertices = hexVertices(cx, cy, size);
const edgeMidpoints = hexEdgeMidpoints(vertices);
return { cx, cy, size, vertices, edgeMidpoints };
}
/** Width of a flat-top hex (vertex to vertex, horizontal) */
export function hexWidth(size: number): number {
return size * 2;
}
/** Height of a flat-top hex (flat edge to flat edge, vertical) */
export function hexHeight(size: number): number {
return size * SQRT3;
}
/**
* Determine which edge of a hex is closest to a pixel point.
* Useful for click-on-edge detection.
*/
export function closestEdge(hexCenter: PixelCoord, size: number, point: PixelCoord): HexEdge {
const vertices = hexVertices(hexCenter.x, hexCenter.y, size);
const midpoints = hexEdgeMidpoints(vertices);
let minDist = Infinity;
let closest = HexEdge.NE;
for (let i = 0; i < 6; i++) {
const mp = midpoints[i];
const dist = Math.hypot(point.x - mp.x, point.y - mp.y);
if (dist < minDist) {
minDist = dist;
closest = i as HexEdge;
}
}
return closest;
}

118
core/edge-connectivity.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { AxialCoord, EdgeMask, HexFeature } from './types.js';
import { HexEdge, OPPOSITE_EDGE, ALL_EDGES } from './types.js';
import { getNeighbor } from './coords.js';
import type { HexMap } from './hex-map.js';
/** Create an edge mask from a list of edges */
export function edgeMask(...edges: HexEdge[]): EdgeMask {
let mask = 0;
for (const e of edges) mask |= (1 << e);
return mask;
}
/** Check if an edge is set in a mask */
export function hasEdge(mask: EdgeMask, edge: HexEdge): boolean {
return (mask & (1 << edge)) !== 0;
}
/** Toggle an edge in a mask */
export function toggleEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask ^ (1 << edge);
}
/** Set an edge in a mask */
export function setEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask | (1 << edge);
}
/** Clear an edge from a mask */
export function clearEdge(mask: EdgeMask, edge: HexEdge): EdgeMask {
return mask & ~(1 << edge);
}
/** Count active edges */
export function edgeCount(mask: EdgeMask): number {
let n = 0;
for (let i = 0; i < 6; i++) {
if (mask & (1 << i)) n++;
}
return n;
}
/** Get list of active edges */
export function connectedEdges(mask: EdgeMask): HexEdge[] {
const result: HexEdge[] = [];
for (let i = 0; i < 6; i++) {
if (mask & (1 << i)) result.push(i as HexEdge);
}
return result;
}
/** Rotate a mask clockwise by N steps (each step = 60 degrees) */
export function rotateMask(mask: EdgeMask, steps: number): EdgeMask {
const s = ((steps % 6) + 6) % 6;
// Rotate the 6 low bits
return ((mask << s) | (mask >> (6 - s))) & 0x3f;
}
export interface ConstraintAction {
coord: AxialCoord;
terrainId: string;
edge: HexEdge;
action: 'add_dead_end';
}
/**
* After a feature edge is set on a hex, check if the neighbor
* needs a corresponding entry. Returns actions to apply.
*/
export function enforceEdgeConstraints(
hexMap: HexMap,
coord: AxialCoord,
terrainId: string,
mask: EdgeMask,
): ConstraintAction[] {
const actions: ConstraintAction[] = [];
for (const edge of ALL_EDGES) {
if (!hasEdge(mask, edge)) continue;
const neighbor = getNeighbor(coord, edge);
const oppositeEdge = OPPOSITE_EDGE[edge];
const neighborTerrain = hexMap.getTerrain(neighbor);
const neighborFeature = neighborTerrain.features.find(f => f.terrainId === terrainId);
if (!neighborFeature || !hasEdge(neighborFeature.edgeMask, oppositeEdge)) {
actions.push({
coord: neighbor,
terrainId,
edge: oppositeEdge,
action: 'add_dead_end',
});
}
}
return actions;
}
/**
* Apply constraint actions to the hex map.
* Adds dead-end features on neighbor hexes.
*/
export function applyConstraintActions(hexMap: HexMap, actions: ConstraintAction[]): void {
for (const action of actions) {
const terrain = hexMap.getTerrain(action.coord);
const existing = terrain.features.find(f => f.terrainId === action.terrainId);
if (existing) {
existing.edgeMask = setEdge(existing.edgeMask, action.edge);
} else {
terrain.features.push({
terrainId: action.terrainId,
edgeMask: edgeMask(action.edge),
});
}
hexMap.setTerrain(action.coord, terrain);
}
}

94
core/hex-grid.ts Normal file
View File

@@ -0,0 +1,94 @@
import type { AxialCoord, PixelCoord } from './types.js';
import { axialToPixel, pixelToAxial, hexWidth, hexHeight } from './coords.js';
/** Rectangular pixel bounds */
export interface PixelBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Iterate all hex coordinates that overlap a rectangular pixel region.
* Returns axial coords for every hex whose center falls within
* the bounds (with one hex margin to avoid edge clipping).
*/
export function getHexesInBounds(
bounds: PixelBounds,
size: number,
origin: PixelCoord = { x: 0, y: 0 },
): AxialCoord[] {
const w = hexWidth(size);
const h = hexHeight(size);
const colStep = w * 3 / 4; // horizontal distance between hex centers
const rowStep = h; // vertical distance between hex centers
// Expand bounds by one hex to catch partial overlaps
const expandedBounds: PixelBounds = {
minX: bounds.minX - w,
minY: bounds.minY - h,
maxX: bounds.maxX + w,
maxY: bounds.maxY + h,
};
// Find the approximate q range
const qMin = Math.floor((expandedBounds.minX - origin.x) / colStep) - 1;
const qMax = Math.ceil((expandedBounds.maxX - origin.x) / colStep) + 1;
const result: AxialCoord[] = [];
for (let q = qMin; q <= qMax; q++) {
// For this q column, find the r range
const colCenterX = origin.x + size * (3 / 2) * q;
const colOffsetY = origin.y + size * (Math.sqrt(3) / 2) * q;
const rMin = Math.floor((expandedBounds.minY - colOffsetY) / rowStep) - 1;
const rMax = Math.ceil((expandedBounds.maxY - colOffsetY) / rowStep) + 1;
for (let r = rMin; r <= rMax; r++) {
const pixel = axialToPixel({ q, r }, size, origin);
// Check if hex center is within the expanded bounds
if (
pixel.x >= expandedBounds.minX && pixel.x <= expandedBounds.maxX &&
pixel.y >= expandedBounds.minY && pixel.y <= expandedBounds.maxY
) {
result.push({ q, r });
}
}
}
return result;
}
/**
* Get the bounding box (in pixels) of the entire hex grid
* that covers a given image size.
*/
export function gridBoundsForImage(
imageWidth: number,
imageHeight: number,
size: number,
origin: PixelCoord = { x: 0, y: 0 },
): { coords: AxialCoord[]; bounds: PixelBounds } {
const bounds: PixelBounds = {
minX: 0,
minY: 0,
maxX: imageWidth,
maxY: imageHeight,
};
const coords = getHexesInBounds(bounds, size, origin);
return { coords, bounds };
}
/**
* Find the hex coordinate at a given pixel position.
*/
export function hexAtPixel(
pixel: PixelCoord,
size: number,
origin: PixelCoord = { x: 0, y: 0 },
): AxialCoord {
return pixelToAxial(pixel, size, origin);
}

129
core/hex-map.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { AxialCoord, HexTerrain, HexFeature, EdgeMask } from './types.js';
import { coordKey, parseCoordKey } from './coords.js';
import { DEFAULT_BASE_TERRAIN } from './terrain.js';
/** Serialized hex map format (for save/load) */
export interface SerializedHexMap {
hexes: Array<{
q: number;
r: number;
base: string;
features: HexFeature[];
}>;
}
/**
* In-memory hex map state.
* Sparse storage: only hexes that differ from default are stored.
*/
export class HexMap {
private data = new Map<string, HexTerrain>();
private _dirty = false;
/** Get terrain for a hex. Returns default if not explicitly set. */
getTerrain(coord: AxialCoord): HexTerrain {
const key = coordKey(coord);
const existing = this.data.get(key);
if (existing) return existing;
return { base: DEFAULT_BASE_TERRAIN, features: [] };
}
/** Set terrain for a hex */
setTerrain(coord: AxialCoord, terrain: HexTerrain): void {
this.data.set(coordKey(coord), terrain);
this._dirty = true;
}
/** Set only the base terrain, preserving features */
setBase(coord: AxialCoord, base: string): void {
const terrain = this.getTerrain(coord);
terrain.base = base;
this.setTerrain(coord, terrain);
}
/** Add or update a linear feature on a hex */
setFeature(coord: AxialCoord, terrainId: string, edgeMask: EdgeMask): void {
const terrain = this.getTerrain(coord);
const existing = terrain.features.find(f => f.terrainId === terrainId);
if (existing) {
existing.edgeMask = edgeMask;
} else {
terrain.features.push({ terrainId, edgeMask });
}
// Remove features with empty mask
terrain.features = terrain.features.filter(f => f.edgeMask !== 0);
this.setTerrain(coord, terrain);
}
/** Remove a feature entirely from a hex */
removeFeature(coord: AxialCoord, terrainId: string): void {
const terrain = this.getTerrain(coord);
terrain.features = terrain.features.filter(f => f.terrainId !== terrainId);
this.setTerrain(coord, terrain);
}
/** Reset a hex to default state */
clearHex(coord: AxialCoord): void {
this.data.delete(coordKey(coord));
this._dirty = true;
}
/** Check if a hex has been explicitly set */
hasHex(coord: AxialCoord): boolean {
return this.data.has(coordKey(coord));
}
/** Get all explicitly set hexes */
getAllHexes(): Array<{ coord: AxialCoord; terrain: HexTerrain }> {
const result: Array<{ coord: AxialCoord; terrain: HexTerrain }> = [];
for (const [key, terrain] of this.data) {
result.push({ coord: parseCoordKey(key), terrain });
}
return result;
}
/** Number of explicitly set hexes */
get size(): number {
return this.data.size;
}
/** Whether the map has unsaved changes */
get dirty(): boolean {
return this._dirty;
}
markClean(): void {
this._dirty = false;
}
/** Serialize for persistence */
serialize(): SerializedHexMap {
return {
hexes: this.getAllHexes().map(({ coord, terrain }) => ({
q: coord.q,
r: coord.r,
base: terrain.base,
features: terrain.features,
})),
};
}
/** Load from serialized data */
static deserialize(data: SerializedHexMap): HexMap {
const map = new HexMap();
for (const hex of data.hexes) {
map.setTerrain({ q: hex.q, r: hex.r }, {
base: hex.base,
features: hex.features,
});
}
map._dirty = false;
return map;
}
/** Clear all data */
clear(): void {
this.data.clear();
this._dirty = true;
}
}

36
core/terrain.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { TerrainType } from './types.js';
export const TERRAIN_TYPES: TerrainType[] = [
// Area fills (drawn first, below linear features)
{ id: 'ocean', name: 'Ocean/Sea', category: 'area', color: '#2a5574', zIndex: 0 },
{ id: 'lake', name: 'Lake', category: 'area', color: '#4a90c4', zIndex: 1 },
{ id: 'plains', name: 'Plains', category: 'area', color: '#c4b060', zIndex: 2 },
{ id: 'farmland', name: 'Farmland', category: 'area', color: '#a4c639', zIndex: 3 },
{ id: 'forest', name: 'Forest', category: 'area', color: '#2d6a2d', zIndex: 4 },
{ id: 'hills', name: 'Hills', category: 'area', color: '#8a7a5a', zIndex: 5 },
{ id: 'mountains', name: 'Mountains', category: 'area', color: '#6a6a6a', zIndex: 6 },
{ id: 'settlement', name: 'Settlement', category: 'area', color: '#8b4513', zIndex: 7 },
// Linear features (drawn on top, have edge connectivity)
{ id: 'river', name: 'River', category: 'linear', color: '#2a7fff', zIndex: 10 },
{ id: 'road', name: 'Road', category: 'linear', color: '#a0522d', zIndex: 11 },
{ id: 'coastline', name: 'Coastline', category: 'linear', color: '#1a4a6a', zIndex: 12 },
];
const terrainMap = new Map<string, TerrainType>(
TERRAIN_TYPES.map(t => [t.id, t]),
);
export function getTerrainType(id: string): TerrainType | undefined {
return terrainMap.get(id);
}
export function getAreaTerrains(): TerrainType[] {
return TERRAIN_TYPES.filter(t => t.category === 'area');
}
export function getLinearTerrains(): TerrainType[] {
return TERRAIN_TYPES.filter(t => t.category === 'linear');
}
/** Default terrain for unpainted hexes */
export const DEFAULT_BASE_TERRAIN = 'plains';

87
core/types.ts Normal file
View File

@@ -0,0 +1,87 @@
/** Axial hex coordinate */
export interface AxialCoord {
q: number;
r: number;
}
/** Pixel position */
export interface PixelCoord {
x: number;
y: number;
}
/** 6 edges of a flat-top hex, clockwise from top-right */
export enum HexEdge {
NE = 0,
E = 1,
SE = 2,
SW = 3,
W = 4,
NW = 5,
}
/** Opposite edge mapping — the edge of the neighbor that faces this edge */
export const OPPOSITE_EDGE: Record<HexEdge, HexEdge> = {
[HexEdge.NE]: HexEdge.SW,
[HexEdge.E]: HexEdge.W,
[HexEdge.SE]: HexEdge.NW,
[HexEdge.SW]: HexEdge.NE,
[HexEdge.W]: HexEdge.E,
[HexEdge.NW]: HexEdge.SE,
};
/** Axial direction offsets per edge (flat-top) */
export const EDGE_DIRECTIONS: Record<HexEdge, AxialCoord> = {
[HexEdge.NE]: { q: 1, r: -1 },
[HexEdge.E]: { q: 1, r: 0 },
[HexEdge.SE]: { q: 0, r: 1 },
[HexEdge.SW]: { q: -1, r: 1 },
[HexEdge.W]: { q: -1, r: 0 },
[HexEdge.NW]: { q: 0, r: -1 },
};
/** All 6 edges in order */
export const ALL_EDGES: HexEdge[] = [
HexEdge.NE,
HexEdge.E,
HexEdge.SE,
HexEdge.SW,
HexEdge.W,
HexEdge.NW,
];
/** 6-bit bitmask indicating which edges a feature crosses */
export type EdgeMask = number;
/** Terrain category */
export type TerrainCategory = 'area' | 'linear';
/** Terrain type definition */
export interface TerrainType {
id: string;
name: string;
category: TerrainCategory;
color: string;
zIndex: number;
}
/** A linear feature on a hex with edge connectivity */
export interface HexFeature {
terrainId: string;
edgeMask: EdgeMask;
}
/** Complete terrain state for a single hex */
export interface HexTerrain {
base: string;
features: HexFeature[];
}
/** Hex geometry for rendering */
export interface HexGeometry {
cx: number;
cy: number;
size: number;
vertices: PixelCoord[];
edgeMidpoints: PixelCoord[];
}