Fix sidebar init, client-side first, coastline rework

- Fix: move terrainPickerUI declaration before createToolbar to avoid
  "can't access lexical declaration before initialization" error
- Hex size is now fixed per map (not adjustable at runtime)
- Client-side first: localStorage for persistence, no server needed
  for editing. Added Export/Import JSON buttons in sidebar.
- Removed server dependency from main.ts init flow
- Coastline rework: routes edge-to-edge like road/river (bezier),
  fills one side with water color (the side away from hex center).
  No longer draws along hex edges — it's a proper dividing curve.
- Simplified map-settings to just grid toggle + opacity slider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-04-07 10:59:29 +00:00
parent 367ba8af07
commit f144063db9
5 changed files with 299 additions and 203 deletions

View File

@@ -1,6 +1,6 @@
import './style/main.css'; import './style/main.css';
import { initMap } from './map/map-init.js'; import { initMap } from './map/map-init.js';
import { HexOverlayLayer } from './map/hex-layer.js'; import { createHexLayer } from './map/hex-layer.js';
import { attachHexInteraction, type HexClickEvent } from './map/hex-interaction.js'; import { attachHexInteraction, type HexClickEvent } from './map/hex-interaction.js';
import { HexMap } from '../core/hex-map.js'; import { HexMap } from '../core/hex-map.js';
import type { AxialCoord, TerrainType } from '../core/types.js'; import type { AxialCoord, TerrainType } from '../core/types.js';
@@ -15,23 +15,23 @@ import {
enforceEdgeConstraints, enforceEdgeConstraints,
applyConstraintActions, applyConstraintActions,
} from '../core/edge-connectivity.js'; } from '../core/edge-connectivity.js';
import * as api from './data/api-client.js';
const STORAGE_KEY = 'hexifyer_map';
// --- State --- // --- State ---
const hexMap = new HexMap(); const hexMap = new HexMap();
let currentMode: ToolMode = 'select'; let currentMode: ToolMode = 'select';
let selectedTerrain: TerrainType | null = null; let selectedTerrain: TerrainType | null = null;
let selectedHex: AxialCoord | null = null; let selectedHex: AxialCoord | null = null;
let hexSize = 48; const hexSize = 48; // Fixed per map — set once
const origin = { x: 0, y: 0 }; const origin = { x: 0, y: 0 };
let currentMapId: number | null = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null; let saveTimeout: ReturnType<typeof setTimeout> | null = null;
// --- Init Map --- // --- Init Map ---
const map = initMap('map'); const map = initMap('map');
// --- Hex Layer --- // --- Hex Layer ---
let hexLayer = new HexOverlayLayer({ const hexLayer = createHexLayer({
hexSize, hexSize,
hexMap, hexMap,
origin, origin,
@@ -44,63 +44,83 @@ hexLayer.addTo(map);
const sidebarEl = document.getElementById('sidebar')!; const sidebarEl = document.getElementById('sidebar')!;
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl); const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
selectedTerrain = terrain;
});
createToolbar(toolbar, (mode) => { createToolbar(toolbar, (mode) => {
currentMode = mode; currentMode = mode;
terrainPickerUI.setMode(mode); terrainPickerUI.setMode(mode);
}); });
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
selectedTerrain = terrain;
});
const hexInspectorUI = createHexInspector(hexInspector); const hexInspectorUI = createHexInspector(hexInspector);
createMapSettings(settings, { hexSize, showGrid: true, opacity: 0.7 }, (s) => { createMapSettings(settings, { showGrid: true, opacity: 0.7 }, (s) => {
if (s.hexSize !== hexSize) {
hexSize = s.hexSize;
rebuildHexLayer(s.showGrid, s.opacity);
} else {
hexLayer.setShowGrid(s.showGrid); hexLayer.setShowGrid(s.showGrid);
hexLayer.setHexOpacity(s.opacity); hexLayer.setHexOpacity(s.opacity);
}
}); });
// --- Rebuild hex layer (when hex size changes) --- // --- Auto-save to localStorage (debounced) ---
function rebuildHexLayer(showGrid: boolean, opacity: number) {
map.removeLayer(hexLayer);
hexLayer = new HexOverlayLayer({
hexSize,
hexMap,
origin,
showGrid,
opacity,
});
hexLayer.addTo(map);
reattachInteraction();
}
// --- Auto-save (debounced) ---
function scheduleSave() { function scheduleSave() {
if (!currentMapId) return;
if (saveTimeout) clearTimeout(saveTimeout); if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => { saveTimeout = setTimeout(() => {
if (!currentMapId || !hexMap.dirty) return; if (!hexMap.dirty) return;
try { try {
const data = hexMap.serialize(); const data = hexMap.serialize();
await api.saveHexes(currentMapId, data.hexes.map(h => ({ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
q: h.q,
r: h.r,
base: h.base,
features: h.features.map(f => ({ terrainId: f.terrainId, edgeMask: f.edgeMask })),
})));
hexMap.markClean(); hexMap.markClean();
console.log(`[save] Saved ${data.hexes.length} hexes`);
} catch (err) { } catch (err) {
console.error('[save] Failed:', err); console.error('[save] localStorage failed:', err);
} }
}, 1000); }, 500);
} }
// --- File export/import ---
function exportMap() {
const data = hexMap.serialize();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hexmap.json';
a.click();
URL.revokeObjectURL(url);
}
function importMap() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result as string);
hexMap.clear();
for (const hex of data.hexes) {
hexMap.setTerrain({ q: hex.q, r: hex.r }, {
base: hex.base,
features: hex.features,
});
}
hexMap.markClean();
hexLayer.redraw();
scheduleSave();
} catch (err) {
console.error('[import] Failed:', err);
}
};
reader.readAsText(file);
});
input.click();
}
// Expose to sidebar buttons
(window as any).__hexifyer = { exportMap, importMap };
// --- Interaction handlers --- // --- Interaction handlers ---
function handleSelect(event: HexClickEvent) { function handleSelect(event: HexClickEvent) {
@@ -142,11 +162,7 @@ function handleFeature(event: HexClickEvent) {
} }
// --- Hex Interaction --- // --- Hex Interaction ---
let detachInteraction: (() => void) | null = null; attachHexInteraction(
function reattachInteraction() {
detachInteraction?.();
detachInteraction = attachHexInteraction(
map, hexSize, origin, map, hexSize, origin,
(event) => { (event) => {
if (currentMode === 'select') handleSelect(event); if (currentMode === 'select') handleSelect(event);
@@ -156,28 +172,14 @@ function reattachInteraction() {
(event) => { (event) => {
if (currentMode === 'paint') handlePaint(event); if (currentMode === 'paint') handlePaint(event);
}, },
); );
}
reattachInteraction(); // --- Load from localStorage ---
try {
// --- Load or create map from API --- const saved = localStorage.getItem(STORAGE_KEY);
async function init() { if (saved) {
try { const data = JSON.parse(saved);
const maps = await api.listMaps(); for (const hex of data.hexes) {
if (maps.length > 0) {
currentMapId = maps[0].id;
console.log(`[init] Loading map "${maps[0].name}" (id: ${currentMapId})`);
} else {
const result = await api.createMap({ name: 'Default Map' });
currentMapId = result.id;
console.log(`[init] Created new map (id: ${currentMapId})`);
}
// Load existing hexes
const hexes = await api.loadHexes(currentMapId);
if (hexes.length > 0) {
for (const hex of hexes) {
hexMap.setTerrain({ q: hex.q, r: hex.r }, { hexMap.setTerrain({ q: hex.q, r: hex.r }, {
base: hex.base, base: hex.base,
features: hex.features, features: hex.features,
@@ -185,11 +187,8 @@ async function init() {
} }
hexMap.markClean(); hexMap.markClean();
hexLayer.redraw(); hexLayer.redraw();
console.log(`[init] Loaded ${hexes.length} hexes`); console.log(`[init] Loaded ${data.hexes.length} hexes from localStorage`);
}
} catch (err) {
console.warn('[init] API not available, running without persistence:', err);
} }
} catch (err) {
console.warn('[init] Failed to load from localStorage:', err);
} }
init();

View File

@@ -5,7 +5,7 @@ import { getHexesInBounds, type PixelBounds } from '../../core/hex-grid.js';
import type { HexMap } from '../../core/hex-map.js'; import type { HexMap } from '../../core/hex-map.js';
import { renderHex } from '../svg/renderer.js'; import { renderHex } from '../svg/renderer.js';
export interface HexLayerOptions extends L.GridLayerOptions { export interface HexLayerOptions {
hexSize: number; hexSize: number;
hexMap: HexMap; hexMap: HexMap;
origin?: { x: number; y: number }; origin?: { x: number; y: number };
@@ -16,41 +16,21 @@ export interface HexLayerOptions extends L.GridLayerOptions {
/** /**
* Leaflet GridLayer that renders the hex overlay using Canvas. * Leaflet GridLayer that renders the hex overlay using Canvas.
* Delegates all hex rendering to svg/renderer.ts. * Uses L.GridLayer.extend() for compatibility with Leaflet's class system.
*/ */
export class HexOverlayLayer extends L.GridLayer { export function createHexLayer(options: HexLayerOptions): L.GridLayer & {
private hexSize: number; setSelectedHex: (coord: AxialCoord | null) => void;
private hexMap: HexMap; setShowGrid: (show: boolean) => void;
private origin: { x: number; y: number }; setHexOpacity: (opacity: number) => void;
private _selectedHex: AxialCoord | null = null; } {
private _showGrid = true; let hexSize = options.hexSize;
private _hexOpacity = 0.7; let hexMap = options.hexMap;
const origin = options.origin ?? { x: 0, y: 0 };
constructor(options: HexLayerOptions) { let selectedHex: AxialCoord | null = options.selectedHex ?? null;
super(options); let showGrid = options.showGrid ?? true;
this.hexSize = options.hexSize; let hexOpacity = options.opacity ?? 0.7;
this.hexMap = options.hexMap;
this.origin = options.origin ?? { x: 0, y: 0 };
this._selectedHex = options.selectedHex ?? null;
this._showGrid = options.showGrid ?? true;
this._hexOpacity = options.opacity ?? 0.7;
}
setSelectedHex(coord: AxialCoord | null): void {
this._selectedHex = coord;
this.redraw();
}
setShowGrid(show: boolean): void {
this._showGrid = show;
this.redraw();
}
setHexOpacity(opacity: number): void {
this._hexOpacity = opacity;
this.redraw();
}
const HexLayer = L.GridLayer.extend({
createTile(coords: L.Coords): HTMLCanvasElement { createTile(coords: L.Coords): HTMLCanvasElement {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const tileSize = this.getTileSize(); const tileSize = this.getTileSize();
@@ -72,28 +52,52 @@ export class HexOverlayLayer extends L.GridLayer {
maxY: sePoint.y * scale, maxY: sePoint.y * scale,
}; };
const hexCoords = getHexesInBounds(bounds, this.hexSize, this.origin); const hexCoords = getHexesInBounds(bounds, hexSize, origin);
for (const coord of hexCoords) { for (const coord of hexCoords) {
const terrain = this.hexMap.getTerrain(coord); const terrain = hexMap.getTerrain(coord);
const pixelCenter = axialToPixel(coord, this.hexSize, this.origin); const pixelCenter = axialToPixel(coord, hexSize, origin);
const localX = (pixelCenter.x - bounds.minX) / scale; const localX = (pixelCenter.x - bounds.minX) / scale;
const localY = (pixelCenter.y - bounds.minY) / scale; const localY = (pixelCenter.y - bounds.minY) / scale;
const localSize = this.hexSize / scale; const localSize = hexSize / scale;
const geom = computeHexGeometry(localX, localY, localSize); const geom = computeHexGeometry(localX, localY, localSize);
const isSelected = this._selectedHex !== null && const isSelected = selectedHex !== null &&
this._selectedHex.q === coord.q && selectedHex.q === coord.q &&
this._selectedHex.r === coord.r; selectedHex.r === coord.r;
renderHex(ctx, geom, terrain, { renderHex(ctx, geom, terrain, {
opacity: this._hexOpacity, opacity: hexOpacity,
showGrid: this._showGrid, showGrid,
selected: isSelected, selected: isSelected,
}); });
} }
return canvas; return canvas;
} },
});
const layer = new HexLayer() as L.GridLayer & {
setSelectedHex: (coord: AxialCoord | null) => void;
setShowGrid: (show: boolean) => void;
setHexOpacity: (opacity: number) => void;
};
layer.setSelectedHex = (coord: AxialCoord | null) => {
selectedHex = coord;
layer.redraw();
};
layer.setShowGrid = (show: boolean) => {
showGrid = show;
layer.redraw();
};
layer.setHexOpacity = (opacity: number) => {
hexOpacity = opacity;
layer.redraw();
};
return layer;
} }

View File

@@ -255,7 +255,7 @@ function drawLinearFeature(
const { cx, cy, edgeMidpoints, vertices } = geom; const { cx, cy, edgeMidpoints, vertices } = geom;
if (terrainId === 'coastline') { if (terrainId === 'coastline') {
drawCoastline(ctx, vertices, edges, color, size); drawCoastlineFeature(ctx, geom, edges, size);
return; return;
} }
@@ -374,41 +374,131 @@ function drawBezierRoute(
ctx.stroke(); ctx.stroke();
} }
function drawCoastline( /**
* Coastline: routes edge-to-edge like road/river, but fills one side with water.
* The water side is the side AWAY from the hex center (the "outside" of the curve).
* We determine this using a cross-product test on the bezier midpoint.
*/
function drawCoastlineFeature(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
vertices: PixelCoord[], geom: HexGeometry,
edges: HexEdge[], edges: HexEdge[],
color: string,
size: number, size: number,
): void { ): void {
ctx.strokeStyle = color; const { cx, cy, edgeMidpoints, vertices } = geom;
ctx.lineWidth = Math.max(2, size / 5); const pairs = pairEdges(edges);
ctx.lineCap = 'round'; const waterColor = '#2a5574';
ctx.setLineDash([]); const coastColor = '#1a4a6a';
// Coastline runs along hex edges between vertices ctx.save();
// Edge i runs from vertex i to vertex (i+1)%6 clipToHex(ctx, geom);
// But our edges are reordered — we need the vertex indices for each HexEdge
// HexEdge NE(0)=vertex pair [5,0], E(1)=[0,1], SE(2)=[1,2],
// SW(3)=[2,3], W(4)=[3,4], NW(5)=[4,5]
const edgeToVertices: Record<HexEdge, [number, number]> = {
[HexEdge.NE]: [5, 0],
[HexEdge.E]: [0, 1],
[HexEdge.SE]: [1, 2],
[HexEdge.SW]: [2, 3],
[HexEdge.W]: [3, 4],
[HexEdge.NW]: [4, 5],
};
for (const edge of edges) { for (const pair of pairs) {
const [vi1, vi2] = edgeToVertices[edge]; if (pair.length === 2) {
const v1 = vertices[vi1]; const p1 = edgeMidpoints[pair[0]];
const v2 = vertices[vi2]; const p2 = edgeMidpoints[pair[1]];
// Control point for the quadratic bezier (through center)
const cp = { x: cx, y: cy };
// Determine which side of the curve is "away" from center
// Sample the bezier midpoint, then offset perpendicular
const bezMidX = 0.25 * p1.x + 0.5 * cp.x + 0.25 * p2.x;
const bezMidY = 0.25 * p1.y + 0.5 * cp.y + 0.25 * p2.y;
// Tangent at midpoint (derivative of quadratic bezier at t=0.5)
const tanX = (p2.x - p1.x);
const tanY = (p2.y - p1.y);
// Perpendicular (rotated 90° CW)
const perpX = tanY;
const perpY = -tanX;
// The "water side" offset: we pick the side that is FARTHER from center
const testX = bezMidX + perpX * 0.1;
const testY = bezMidY + perpY * 0.1;
const distFromCenter = Math.hypot(testX - cx, testY - cy);
const distOther = Math.hypot(bezMidX - perpX * 0.1 - cx, bezMidY - perpY * 0.1 - cy);
const waterSide = distFromCenter > distOther ? 1 : -1;
// Fill the water side: build a path from the curve to the hex boundary on that side
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(v1.x, v1.y); ctx.moveTo(p1.x, p1.y);
ctx.lineTo(v2.x, v2.y); ctx.quadraticCurveTo(cp.x, cp.y, p2.x, p2.y);
// Walk hex vertices on the water side from p2's edge to p1's edge
const waterVerts = getVerticesOnSide(pair[0], pair[1], waterSide, vertices);
for (const v of waterVerts) {
ctx.lineTo(v.x, v.y);
}
ctx.closePath();
ctx.fillStyle = waterColor;
ctx.globalAlpha = 0.6;
ctx.fill();
ctx.globalAlpha = 1;
// Draw the coastline stroke
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.quadraticCurveTo(cp.x, cp.y, p2.x, p2.y);
ctx.strokeStyle = coastColor;
ctx.lineWidth = Math.max(1.5, size / 10);
ctx.stroke();
} else {
// Dead-end: just draw as a line to center
const mp = edgeMidpoints[pair[0]];
ctx.beginPath();
ctx.moveTo(mp.x, mp.y);
ctx.lineTo(cx, cy);
ctx.strokeStyle = coastColor;
ctx.lineWidth = Math.max(1.5, size / 10);
ctx.stroke(); ctx.stroke();
} }
}
ctx.restore();
}
/**
* Get hex vertices on one side of a coastline running from edge1 to edge2.
* side: 1 = clockwise from edge2 to edge1, -1 = counter-clockwise.
* Returns vertices between the two edge midpoints, walking around the hex boundary.
*/
function getVerticesOnSide(
edge1: HexEdge,
edge2: HexEdge,
side: number,
vertices: PixelCoord[],
): PixelCoord[] {
// Map HexEdge to the vertex AFTER the edge midpoint (clockwise)
// Edge NE(0) midpoint is between vertex 5 and 0 → next vertex CW = 0
// Edge E(1) → 1, SE(2) → 2, SW(3) → 3, W(4) → 4, NW(5) → 5
const edgeToNextVertex = [0, 1, 2, 3, 4, 5];
const result: PixelCoord[] = [];
const startVertIdx = edgeToNextVertex[edge2];
const endVertIdx = (edgeToNextVertex[edge1] + 5) % 6; // vertex BEFORE edge1's midpoint
if (side > 0) {
// Walk clockwise from edge2's next vertex to edge1's previous vertex
let idx = startVertIdx;
for (let i = 0; i < 6; i++) {
result.push(vertices[idx]);
if (idx === endVertIdx) break;
idx = (idx + 1) % 6;
}
} else {
// Walk counter-clockwise
let idx = (startVertIdx + 5) % 6;
for (let i = 0; i < 6; i++) {
result.push(vertices[idx]);
if (idx === (endVertIdx + 1) % 6) break;
idx = (idx + 5) % 6;
}
}
return result;
} }
// --- Main render function --- // --- Main render function ---

View File

@@ -1,5 +1,4 @@
export interface MapSettings { export interface MapSettings {
hexSize: number;
showGrid: boolean; showGrid: boolean;
opacity: number; opacity: number;
} }
@@ -14,22 +13,6 @@ export function createMapSettings(
function render() { function render() {
container.innerHTML = ''; 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 // Show grid
const gridRow = document.createElement('div'); const gridRow = document.createElement('div');
gridRow.className = 'setting-row'; gridRow.className = 'setting-row';

View File

@@ -34,10 +34,30 @@ export function createSidebar(container: HTMLElement): {
settings.id = 'settings'; settings.id = 'settings';
settingsSection.appendChild(settings); settingsSection.appendChild(settings);
// File operations
const fileSection = document.createElement('div');
fileSection.className = 'sidebar-section';
fileSection.innerHTML = '<h3>File</h3>';
const fileButtons = document.createElement('div');
fileButtons.className = 'toolbar';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', () => (window as any).__hexifyer?.exportMap());
const importBtn = document.createElement('button');
importBtn.textContent = 'Import';
importBtn.addEventListener('click', () => (window as any).__hexifyer?.importMap());
fileButtons.appendChild(exportBtn);
fileButtons.appendChild(importBtn);
fileSection.appendChild(fileButtons);
container.appendChild(toolbarSection); container.appendChild(toolbarSection);
container.appendChild(terrainSection); container.appendChild(terrainSection);
container.appendChild(inspectorSection); container.appendChild(inspectorSection);
container.appendChild(settingsSection); container.appendChild(settingsSection);
container.appendChild(fileSection);
return { toolbar, terrainPicker, hexInspector, settings }; return { toolbar, terrainPicker, hexInspector, settings };
} }