diff --git a/src/main.ts b/src/main.ts index 93e0dd9..02747d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 | 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(); diff --git a/src/map/hex-layer.ts b/src/map/hex-layer.ts index fb5ff0e..829d3b8 100644 --- a/src/map/hex-layer.ts +++ b/src/map/hex-layer.ts @@ -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; } diff --git a/src/svg/renderer.ts b/src/svg/renderer.ts index ff647e0..0b6e115 100644 --- a/src/svg/renderer.ts +++ b/src/svg/renderer.ts @@ -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.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 --- diff --git a/src/ui/map-settings.ts b/src/ui/map-settings.ts index a279a10..b8f70b5 100644 --- a/src/ui/map-settings.ts +++ b/src/ui/map-settings.ts @@ -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 = ``; - 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'; diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts index 3875d75..0c9e263 100644 --- a/src/ui/sidebar.ts +++ b/src/ui/sidebar.ts @@ -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 = '

File

'; + 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 }; }