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

52
src/ui/hex-inspector.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { AxialCoord, HexTerrain } from '../../core/types.js';
import { getTerrainType } from '../../core/terrain.js';
import { connectedEdges } from '../../core/edge-connectivity.js';
import { HexEdge } from '../../core/types.js';
const EDGE_NAMES: Record<HexEdge, string> = {
[HexEdge.NE]: 'NE',
[HexEdge.E]: 'E',
[HexEdge.SE]: 'SE',
[HexEdge.SW]: 'SW',
[HexEdge.W]: 'W',
[HexEdge.NW]: 'NW',
};
export function createHexInspector(container: HTMLElement): {
update: (coord: AxialCoord | null, terrain: HexTerrain | null) => void;
} {
function update(coord: AxialCoord | null, terrain: HexTerrain | null) {
if (!coord || !terrain) {
container.innerHTML = '<div style="color:#666;font-size:12px">No hex selected</div>';
return;
}
const baseType = getTerrainType(terrain.base);
let html = '<div class="hex-info">';
html += `<div class="coord">q: ${coord.q}, r: ${coord.r}</div>`;
html += `<div class="terrain-label">`;
html += `<span class="terrain-swatch" style="background:${baseType?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
html += `${baseType?.name ?? terrain.base}`;
html += `</div>`;
if (terrain.features.length > 0) {
html += '<div style="margin-top:6px;font-size:11px;color:#888">Features:</div>';
for (const feature of terrain.features) {
const type = getTerrainType(feature.terrainId);
const edges = connectedEdges(feature.edgeMask)
.map(e => EDGE_NAMES[e])
.join(', ');
html += `<div class="terrain-label" style="margin-top:2px">`;
html += `<span class="terrain-swatch" style="background:${type?.color ?? '#666'};display:inline-block;width:10px;height:10px;border-radius:2px"></span>`;
html += `${type?.name ?? feature.terrainId}: ${edges}`;
html += `</div>`;
}
}
html += '</div>';
container.innerHTML = html;
}
update(null, null);
return { update };
}

67
src/ui/map-settings.ts Normal file
View File

@@ -0,0 +1,67 @@
export interface MapSettings {
hexSize: number;
showGrid: boolean;
opacity: number;
}
export function createMapSettings(
container: HTMLElement,
initial: MapSettings,
onChange: (settings: MapSettings) => void,
): { getSettings: () => MapSettings } {
const settings = { ...initial };
function render() {
container.innerHTML = '';
// Hex size
const sizeRow = document.createElement('div');
sizeRow.className = 'setting-row';
sizeRow.innerHTML = `<label>Hex size (px)</label>`;
const sizeInput = document.createElement('input');
sizeInput.type = 'number';
sizeInput.min = '8';
sizeInput.max = '256';
sizeInput.value = String(settings.hexSize);
sizeInput.addEventListener('change', () => {
settings.hexSize = Math.max(8, Math.min(256, Number(sizeInput.value)));
onChange(settings);
});
sizeRow.appendChild(sizeInput);
container.appendChild(sizeRow);
// Show grid
const gridRow = document.createElement('div');
gridRow.className = 'setting-row';
gridRow.innerHTML = `<label>Show grid</label>`;
const gridCheck = document.createElement('input');
gridCheck.type = 'checkbox';
gridCheck.checked = settings.showGrid;
gridCheck.addEventListener('change', () => {
settings.showGrid = gridCheck.checked;
onChange(settings);
});
gridRow.appendChild(gridCheck);
container.appendChild(gridRow);
// Opacity
const opacityRow = document.createElement('div');
opacityRow.className = 'setting-row';
opacityRow.innerHTML = `<label>Opacity</label>`;
const opacityInput = document.createElement('input');
opacityInput.type = 'range';
opacityInput.min = '0';
opacityInput.max = '100';
opacityInput.value = String(Math.round(settings.opacity * 100));
opacityInput.addEventListener('input', () => {
settings.opacity = Number(opacityInput.value) / 100;
onChange(settings);
});
opacityRow.appendChild(opacityInput);
container.appendChild(opacityRow);
}
render();
return { getSettings: () => ({ ...settings }) };
}

43
src/ui/sidebar.ts Normal file
View File

@@ -0,0 +1,43 @@
export function createSidebar(container: HTMLElement): {
toolbar: HTMLElement;
terrainPicker: HTMLElement;
hexInspector: HTMLElement;
settings: HTMLElement;
} {
container.innerHTML = '';
const toolbarSection = document.createElement('div');
toolbarSection.className = 'sidebar-section';
toolbarSection.innerHTML = '<h3>Tools</h3>';
const toolbar = document.createElement('div');
toolbar.id = 'toolbar';
toolbarSection.appendChild(toolbar);
const terrainSection = document.createElement('div');
terrainSection.className = 'sidebar-section';
terrainSection.innerHTML = '<h3>Terrain</h3>';
const terrainPicker = document.createElement('div');
terrainPicker.id = 'terrain-picker';
terrainSection.appendChild(terrainPicker);
const inspectorSection = document.createElement('div');
inspectorSection.className = 'sidebar-section';
inspectorSection.innerHTML = '<h3>Selected Hex</h3>';
const hexInspector = document.createElement('div');
hexInspector.id = 'hex-inspector';
inspectorSection.appendChild(hexInspector);
const settingsSection = document.createElement('div');
settingsSection.className = 'sidebar-section';
settingsSection.innerHTML = '<h3>Settings</h3>';
const settings = document.createElement('div');
settings.id = 'settings';
settingsSection.appendChild(settings);
container.appendChild(toolbarSection);
container.appendChild(terrainSection);
container.appendChild(inspectorSection);
container.appendChild(settingsSection);
return { toolbar, terrainPicker, hexInspector, settings };
}

80
src/ui/terrain-picker.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { TerrainType } from '../../core/types.js';
import { getAreaTerrains, getLinearTerrains } from '../../core/terrain.js';
import type { ToolMode } from './toolbar.js';
export function createTerrainPicker(
container: HTMLElement,
onChange: (terrain: TerrainType) => void,
): {
setMode: (mode: ToolMode) => void;
getSelected: () => TerrainType | null;
} {
let selected: TerrainType | null = null;
let currentMode: ToolMode = 'select';
function render() {
container.innerHTML = '';
if (currentMode === 'select') {
container.innerHTML = '<div style="color:#666;font-size:12px">Click a hex to inspect it</div>';
return;
}
const terrains = currentMode === 'paint' ? getAreaTerrains() : getLinearTerrains();
const label = currentMode === 'paint' ? 'Area Terrain' : 'Linear Features';
const sectionLabel = document.createElement('div');
sectionLabel.className = 'terrain-section-label';
sectionLabel.textContent = label;
container.appendChild(sectionLabel);
const grid = document.createElement('div');
grid.className = 'terrain-grid';
for (const terrain of terrains) {
const btn = document.createElement('button');
btn.className = 'terrain-btn';
if (selected?.id === terrain.id) btn.classList.add('selected');
const swatch = document.createElement('span');
swatch.className = 'terrain-swatch';
swatch.style.backgroundColor = terrain.color;
const name = document.createElement('span');
name.textContent = terrain.name;
btn.appendChild(swatch);
btn.appendChild(name);
btn.addEventListener('click', () => {
selected = terrain;
onChange(terrain);
render();
});
grid.appendChild(btn);
}
container.appendChild(grid);
// Auto-select first if nothing selected
if (!selected || !terrains.find(t => t.id === selected!.id)) {
selected = terrains[0] ?? null;
if (selected) onChange(selected);
// Re-render to show selection
const firstBtn = grid.querySelector('.terrain-btn');
firstBtn?.classList.add('selected');
}
}
render();
return {
setMode(mode: ToolMode) {
currentMode = mode;
selected = null;
render();
},
getSelected: () => selected,
};
}

40
src/ui/toolbar.ts Normal file
View File

@@ -0,0 +1,40 @@
export type ToolMode = 'select' | 'paint' | 'feature';
export function createToolbar(
container: HTMLElement,
onChange: (mode: ToolMode) => void,
): { setMode: (mode: ToolMode) => void } {
let currentMode: ToolMode = 'select';
const div = document.createElement('div');
div.className = 'toolbar';
const modes: { mode: ToolMode; label: string }[] = [
{ mode: 'select', label: 'Select' },
{ mode: 'paint', label: 'Paint' },
{ mode: 'feature', label: 'Feature' },
];
const buttons: Map<ToolMode, HTMLButtonElement> = new Map();
for (const { mode, label } of modes) {
const btn = document.createElement('button');
btn.textContent = label;
btn.addEventListener('click', () => setMode(mode));
buttons.set(mode, btn);
div.appendChild(btn);
}
function setMode(mode: ToolMode) {
currentMode = mode;
for (const [m, btn] of buttons) {
btn.classList.toggle('active', m === currentMode);
}
onChange(currentMode);
}
container.appendChild(div);
setMode('select');
return { setMode };
}