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 { 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 { HexMap } from '../core/hex-map.js';
import type { AxialCoord, TerrainType } from '../core/types.js';
@@ -15,23 +15,23 @@ import {
enforceEdgeConstraints,
applyConstraintActions,
} from '../core/edge-connectivity.js';
import * as api from './data/api-client.js';
const STORAGE_KEY = 'hexifyer_map';
// --- State ---
const hexMap = new HexMap();
let currentMode: ToolMode = 'select';
let selectedTerrain: TerrainType | null = null;
let selectedHex: AxialCoord | null = null;
let hexSize = 48;
const hexSize = 48; // Fixed per map — set once
const origin = { x: 0, y: 0 };
let currentMapId: number | null = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
// --- Init Map ---
const map = initMap('map');
// --- Hex Layer ---
let hexLayer = new HexOverlayLayer({
const hexLayer = createHexLayer({
hexSize,
hexMap,
origin,
@@ -44,63 +44,83 @@ hexLayer.addTo(map);
const sidebarEl = document.getElementById('sidebar')!;
const { toolbar, terrainPicker, hexInspector, settings } = createSidebar(sidebarEl);
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
selectedTerrain = terrain;
});
createToolbar(toolbar, (mode) => {
currentMode = mode;
terrainPickerUI.setMode(mode);
});
const terrainPickerUI = createTerrainPicker(terrainPicker, (terrain) => {
selectedTerrain = terrain;
});
const hexInspectorUI = createHexInspector(hexInspector);
createMapSettings(settings, { hexSize, showGrid: true, opacity: 0.7 }, (s) => {
if (s.hexSize !== hexSize) {
hexSize = s.hexSize;
rebuildHexLayer(s.showGrid, s.opacity);
} else {
hexLayer.setShowGrid(s.showGrid);
hexLayer.setHexOpacity(s.opacity);
}
createMapSettings(settings, { showGrid: true, opacity: 0.7 }, (s) => {
hexLayer.setShowGrid(s.showGrid);
hexLayer.setHexOpacity(s.opacity);
});
// --- Rebuild hex layer (when hex size changes) ---
function rebuildHexLayer(showGrid: boolean, opacity: number) {
map.removeLayer(hexLayer);
hexLayer = new HexOverlayLayer({
hexSize,
hexMap,
origin,
showGrid,
opacity,
});
hexLayer.addTo(map);
reattachInteraction();
}
// --- Auto-save (debounced) ---
// --- Auto-save to localStorage (debounced) ---
function scheduleSave() {
if (!currentMapId) return;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
if (!currentMapId || !hexMap.dirty) return;
saveTimeout = setTimeout(() => {
if (!hexMap.dirty) return;
try {
const data = hexMap.serialize();
await api.saveHexes(currentMapId, data.hexes.map(h => ({
q: h.q,
r: h.r,
base: h.base,
features: h.features.map(f => ({ terrainId: f.terrainId, edgeMask: f.edgeMask })),
})));
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
hexMap.markClean();
console.log(`[save] Saved ${data.hexes.length} hexes`);
} 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 ---
function handleSelect(event: HexClickEvent) {
@@ -142,54 +162,33 @@ function handleFeature(event: HexClickEvent) {
}
// --- Hex Interaction ---
let detachInteraction: (() => void) | null = null;
attachHexInteraction(
map, hexSize, origin,
(event) => {
if (currentMode === 'select') handleSelect(event);
else if (currentMode === 'paint') handlePaint(event);
else if (currentMode === 'feature') handleFeature(event);
},
(event) => {
if (currentMode === 'paint') handlePaint(event);
},
);
function reattachInteraction() {
detachInteraction?.();
detachInteraction = attachHexInteraction(
map, hexSize, origin,
(event) => {
if (currentMode === 'select') handleSelect(event);
else if (currentMode === 'paint') handlePaint(event);
else if (currentMode === 'feature') handleFeature(event);
},
(event) => {
if (currentMode === 'paint') handlePaint(event);
},
);
}
reattachInteraction();
// --- Load or create map from API ---
async function init() {
try {
const maps = await api.listMaps();
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 from localStorage ---
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const data = JSON.parse(saved);
for (const hex of data.hexes) {
hexMap.setTerrain({ q: hex.q, r: hex.r }, {
base: hex.base,
features: hex.features,
});
}
// 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 }, {
base: hex.base,
features: hex.features,
});
}
hexMap.markClean();
hexLayer.redraw();
console.log(`[init] Loaded ${hexes.length} hexes`);
}
} catch (err) {
console.warn('[init] API not available, running without persistence:', err);
hexMap.markClean();
hexLayer.redraw();
console.log(`[init] Loaded ${data.hexes.length} hexes from localStorage`);
}
} 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 { renderHex } from '../svg/renderer.js';
export interface HexLayerOptions extends L.GridLayerOptions {
export interface HexLayerOptions {
hexSize: number;
hexMap: HexMap;
origin?: { x: number; y: number };
@@ -16,84 +16,88 @@ export interface HexLayerOptions extends L.GridLayerOptions {
/**
* 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 {
private hexSize: number;
private hexMap: HexMap;
private origin: { x: number; y: number };
private _selectedHex: AxialCoord | null = null;
private _showGrid = true;
private _hexOpacity = 0.7;
export function createHexLayer(options: HexLayerOptions): L.GridLayer & {
setSelectedHex: (coord: AxialCoord | null) => void;
setShowGrid: (show: boolean) => void;
setHexOpacity: (opacity: number) => void;
} {
let hexSize = options.hexSize;
let hexMap = options.hexMap;
const origin = options.origin ?? { x: 0, y: 0 };
let selectedHex: AxialCoord | null = options.selectedHex ?? null;
let showGrid = options.showGrid ?? true;
let hexOpacity = options.opacity ?? 0.7;
constructor(options: HexLayerOptions) {
super(options);
this.hexSize = options.hexSize;
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;
}
const HexLayer = L.GridLayer.extend({
createTile(coords: L.Coords): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const tileSize = this.getTileSize();
canvas.width = tileSize.x;
canvas.height = tileSize.y;
const ctx = canvas.getContext('2d')!;
setSelectedHex(coord: AxialCoord | null): void {
this._selectedHex = coord;
this.redraw();
}
const nwPoint = coords.scaleBy(tileSize);
const sePoint = nwPoint.add(tileSize);
setShowGrid(show: boolean): void {
this._showGrid = show;
this.redraw();
}
const zoom = coords.z;
const maxZoom = this._map?.getMaxZoom() ?? 6;
const scale = Math.pow(2, maxZoom - zoom);
setHexOpacity(opacity: number): void {
this._hexOpacity = opacity;
this.redraw();
}
const bounds: PixelBounds = {
minX: nwPoint.x * scale,
minY: nwPoint.y * scale,
maxX: sePoint.x * scale,
maxY: sePoint.y * scale,
};
createTile(coords: L.Coords): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const tileSize = this.getTileSize();
canvas.width = tileSize.x;
canvas.height = tileSize.y;
const ctx = canvas.getContext('2d')!;
const hexCoords = getHexesInBounds(bounds, hexSize, origin);
const nwPoint = coords.scaleBy(tileSize);
const sePoint = nwPoint.add(tileSize);
for (const coord of hexCoords) {
const terrain = hexMap.getTerrain(coord);
const pixelCenter = axialToPixel(coord, hexSize, origin);
const zoom = coords.z;
const maxZoom = this._map?.getMaxZoom() ?? 6;
const scale = Math.pow(2, maxZoom - zoom);
const localX = (pixelCenter.x - bounds.minX) / scale;
const localY = (pixelCenter.y - bounds.minY) / scale;
const localSize = hexSize / scale;
const bounds: PixelBounds = {
minX: nwPoint.x * scale,
minY: nwPoint.y * scale,
maxX: sePoint.x * scale,
maxY: sePoint.y * scale,
};
const geom = computeHexGeometry(localX, localY, localSize);
const isSelected = selectedHex !== null &&
selectedHex.q === coord.q &&
selectedHex.r === coord.r;
const hexCoords = getHexesInBounds(bounds, this.hexSize, this.origin);
renderHex(ctx, geom, terrain, {
opacity: hexOpacity,
showGrid,
selected: isSelected,
});
}
for (const coord of hexCoords) {
const terrain = this.hexMap.getTerrain(coord);
const pixelCenter = axialToPixel(coord, this.hexSize, this.origin);
return canvas;
},
});
const localX = (pixelCenter.x - bounds.minX) / scale;
const localY = (pixelCenter.y - bounds.minY) / scale;
const localSize = this.hexSize / scale;
const layer = new HexLayer() as L.GridLayer & {
setSelectedHex: (coord: AxialCoord | null) => void;
setShowGrid: (show: boolean) => void;
setHexOpacity: (opacity: number) => void;
};
const geom = computeHexGeometry(localX, localY, localSize);
const isSelected = this._selectedHex !== null &&
this._selectedHex.q === coord.q &&
this._selectedHex.r === coord.r;
layer.setSelectedHex = (coord: AxialCoord | null) => {
selectedHex = coord;
layer.redraw();
};
renderHex(ctx, geom, terrain, {
opacity: this._hexOpacity,
showGrid: this._showGrid,
selected: isSelected,
});
}
layer.setShowGrid = (show: boolean) => {
showGrid = show;
layer.redraw();
};
return canvas;
}
layer.setHexOpacity = (opacity: number) => {
hexOpacity = opacity;
layer.redraw();
};
return layer;
}

View File

@@ -255,7 +255,7 @@ function drawLinearFeature(
const { cx, cy, edgeMidpoints, vertices } = geom;
if (terrainId === 'coastline') {
drawCoastline(ctx, vertices, edges, color, size);
drawCoastlineFeature(ctx, geom, edges, size);
return;
}
@@ -374,41 +374,131 @@ function drawBezierRoute(
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,
vertices: PixelCoord[],
geom: HexGeometry,
edges: HexEdge[],
color: string,
size: number,
): void {
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, size / 5);
ctx.lineCap = 'round';
ctx.setLineDash([]);
const { cx, cy, edgeMidpoints, vertices } = geom;
const pairs = pairEdges(edges);
const waterColor = '#2a5574';
const coastColor = '#1a4a6a';
// Coastline runs along hex edges between vertices
// Edge i runs from vertex i to vertex (i+1)%6
// 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],
};
ctx.save();
clipToHex(ctx, geom);
for (const edge of edges) {
const [vi1, vi2] = edgeToVertices[edge];
const v1 = vertices[vi1];
const v2 = vertices[vi2];
ctx.beginPath();
ctx.moveTo(v1.x, v1.y);
ctx.lineTo(v2.x, v2.y);
ctx.stroke();
for (const pair of pairs) {
if (pair.length === 2) {
const p1 = edgeMidpoints[pair[0]];
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.moveTo(p1.x, p1.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.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 ---

View File

@@ -1,5 +1,4 @@
export interface MapSettings {
hexSize: number;
showGrid: boolean;
opacity: number;
}
@@ -14,22 +13,6 @@ export function createMapSettings(
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';

View File

@@ -34,10 +34,30 @@ export function createSidebar(container: HTMLElement): {
settings.id = '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(terrainSection);
container.appendChild(inspectorSection);
container.appendChild(settingsSection);
container.appendChild(fileSection);
return { toolbar, terrainPicker, hexInspector, settings };
}