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:
52
src/ui/hex-inspector.ts
Normal file
52
src/ui/hex-inspector.ts
Normal 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
67
src/ui/map-settings.ts
Normal 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
43
src/ui/sidebar.ts
Normal 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
80
src/ui/terrain-picker.ts
Normal 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
40
src/ui/toolbar.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user