Replace placeholder coastline with TESSERA H7 fog-of-war map
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { WAYPOINTS, ROUTES } from '../constants.js'
|
import { WAYPOINTS, ROUTES } from '../constants.js'
|
||||||
|
import { fetchMapCells } from '../api.js'
|
||||||
|
|
||||||
|
// Bounding box — Mediterranean basin
|
||||||
|
const BBOX = { minLng: 5, maxLng: 38, minLat: 28, maxLat: 48 }
|
||||||
|
const W = 800
|
||||||
|
const H = 460
|
||||||
|
|
||||||
// H3 res-5 cell centre coordinates — hardcoded, h3-js not in browser bundle
|
// H3 res-5 cell centre coordinates — hardcoded, h3-js not in browser bundle
|
||||||
// Cross-checked against constants.js H3 IDs
|
|
||||||
const CENTRES = {
|
const CENTRES = {
|
||||||
ostia: { lat: 41.73, lng: 12.23 },
|
ostia: { lat: 41.73, lng: 12.23 },
|
||||||
capua: { lat: 41.09, lng: 14.21 },
|
capua: { lat: 41.09, lng: 14.21 },
|
||||||
@@ -10,10 +16,21 @@ const CENTRES = {
|
|||||||
alexandria: { lat: 31.20, lng: 29.92 },
|
alexandria: { lat: 31.20, lng: 29.92 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounding box clipped to relevant Mediterranean — no Atlantic needed
|
// H3 res-5 hex string IDs — used for API calls
|
||||||
const BBOX = { minLng: 5, maxLng: 38, minLat: 28, maxLat: 48 }
|
const H5_IDS = {
|
||||||
const W = 800
|
ostia: '851e805bfffffff',
|
||||||
const H = 460
|
capua: '851e8333fffffff',
|
||||||
|
brundisium: '851e8ba3fffffff',
|
||||||
|
carthago: '85386e23fffffff',
|
||||||
|
alexandria: '853f5ba7fffffff',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WAYPOINT_IDS = ['ostia', 'capua', 'brundisium', 'carthago', 'alexandria']
|
||||||
|
const ROUTE_ORDER = ['olive', 'wine', 'grain', 'linen']
|
||||||
|
|
||||||
|
// Active epoch — Roman period for OTIVM-II
|
||||||
|
// sl_offset_cm = -10 (effectively zero). Coastline matches modern sea level.
|
||||||
|
const EPOCH = 'roman_14bce'
|
||||||
|
|
||||||
function project(lat, lng) {
|
function project(lat, lng) {
|
||||||
const x = ((lng - BBOX.minLng) / (BBOX.maxLng - BBOX.minLng)) * W
|
const x = ((lng - BBOX.minLng) / (BBOX.maxLng - BBOX.minLng)) * W
|
||||||
@@ -21,56 +38,47 @@ function project(lat, lng) {
|
|||||||
return { x, y }
|
return { x, y }
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePath(pts) {
|
// Approximate H7 cell position within its H5 parent using a sunflower spiral.
|
||||||
return pts.map(([lat, lng], i) => {
|
// H7 cells are rendered as circles — exact hex geometry requires h3-js (server-side only).
|
||||||
const { x, y } = project(lat, lng)
|
// The spiral distributes 49 cells across the H5 area. Visual approximation only.
|
||||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
// Will be replaced when the API returns per-cell centroids.
|
||||||
}).join(' ') + ' Z'
|
function h7ApproxPos(h5Id, cellIndex) {
|
||||||
|
const cx = CENTRES[h5Id]
|
||||||
|
if (!cx) return null
|
||||||
|
const { x: cx_px, y: cy_px } = project(cx.lat, cx.lng)
|
||||||
|
// Golden angle spiral — distributes points evenly across H5 area (~90km)
|
||||||
|
// At our projection scale, H5 spans ~120px. Space cells ~16px apart.
|
||||||
|
const SPACING = 14
|
||||||
|
const angle = cellIndex * 2.399963 // golden angle radians
|
||||||
|
const radius = SPACING * Math.sqrt(cellIndex)
|
||||||
|
return {
|
||||||
|
x: cx_px + Math.cos(angle) * radius,
|
||||||
|
y: cy_px + Math.sin(angle) * radius,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Europe + Asia Minor mainland polygon
|
const H7_RADIUS = 8
|
||||||
// Traces: French Riviera → Italian peninsula → Adriatic → Greece → Thrace →
|
|
||||||
// top edge east → Turkey south coast → top edge west → close
|
|
||||||
const EUROPE = [
|
|
||||||
[48.0,5.0],
|
|
||||||
[43.4,5.0],[43.5,5.3],[43.8,7.4],
|
|
||||||
[43.0,9.4],[41.9,8.7],[41.2,9.3],[39.8,9.6],[39.0,8.9],[38.4,9.4],
|
|
||||||
[37.9,11.7],[37.5,15.0],[38.1,15.7],
|
|
||||||
[40.1,18.4],[40.9,15.6],[41.7,13.7],[43.5,13.5],[44.4,12.3],
|
|
||||||
[45.5,13.8],[45.8,13.6],[45.2,14.0],[44.5,14.5],[43.5,16.9],
|
|
||||||
[42.4,18.5],[41.3,19.4],
|
|
||||||
[40.5,23.0],[40.0,23.5],[38.2,23.6],[37.5,22.4],[36.7,21.9],
|
|
||||||
[37.5,21.1],[38.5,22.0],[39.4,22.5],[40.0,22.6],[40.6,22.9],
|
|
||||||
[41.0,23.5],[41.5,24.5],[41.7,26.4],[41.5,28.0],[41.2,29.0],
|
|
||||||
[48.0,29.0],[48.0,38.0],
|
|
||||||
[36.8,36.6],[36.5,36.2],[36.8,35.1],[36.5,34.6],[36.2,33.3],
|
|
||||||
[36.2,32.0],[36.5,31.0],[37.0,30.0],[37.3,28.9],[37.5,27.5],
|
|
||||||
[37.0,27.2],[38.3,26.9],[38.6,26.8],[39.1,26.4],[39.6,26.2],
|
|
||||||
[40.3,26.4],[40.8,26.0],
|
|
||||||
[48.0,5.0],
|
|
||||||
]
|
|
||||||
|
|
||||||
// North Africa mainland polygon
|
|
||||||
// Traces: Morocco → Algeria → Tunisia (with Cape Bon) → Libya → Egypt →
|
|
||||||
// Levant coast → east clip edge → south clip → close
|
|
||||||
const AFRICA = [
|
|
||||||
[28.0,5.0],
|
|
||||||
[35.8,5.0],[35.2,5.7],[36.9,6.8],[37.2,8.5],[37.1,9.2],
|
|
||||||
[36.8,10.3],[37.1,10.8],[37.1,11.0],
|
|
||||||
[36.8,11.1],[35.5,11.1],[33.9,11.0],[33.6,11.6],
|
|
||||||
[33.0,12.0],[32.8,13.3],[32.1,15.0],
|
|
||||||
[30.8,18.5],[30.5,20.0],[30.9,20.1],
|
|
||||||
[31.2,25.0],[31.5,25.2],[31.5,28.0],[31.5,32.4],
|
|
||||||
[31.0,32.5],[31.2,33.8],[31.5,34.8],[32.0,34.7],[33.1,35.2],[35.5,36.2],
|
|
||||||
[35.0,38.0],[28.0,38.0],
|
|
||||||
[28.0,5.0],
|
|
||||||
]
|
|
||||||
|
|
||||||
const ROUTE_ORDER = ['olive', 'wine', 'grain', 'linen']
|
|
||||||
const WAYPOINT_IDS = ['ostia', 'capua', 'brundisium', 'carthago', 'alexandria']
|
|
||||||
|
|
||||||
export default function Map({ state }) {
|
export default function Map({ state }) {
|
||||||
const chapter = state?.chapter ?? 1
|
const chapter = state?.chapter ?? 1
|
||||||
|
const [cellData, setCellData] = useState({})
|
||||||
|
|
||||||
|
// Fetch H7 cells for all revealed waypoints.
|
||||||
|
// A waypoint is revealed when chapter >= waypoint.chapter.
|
||||||
|
// Ostia (chapter 1) is revealed from the start.
|
||||||
|
// Cells are fetched once per waypoint and cached in state.
|
||||||
|
useEffect(() => {
|
||||||
|
WAYPOINT_IDS.forEach((id) => {
|
||||||
|
const wp = WAYPOINTS[id]
|
||||||
|
if (chapter < wp.chapter) return // not yet revealed
|
||||||
|
if (cellData[id]) return // already loaded
|
||||||
|
fetchMapCells(H5_IDS[id], EPOCH).then(data => {
|
||||||
|
if (data?.cells) {
|
||||||
|
setCellData(prev => ({ ...prev, [id]: data.cells }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [chapter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const pts = {}
|
const pts = {}
|
||||||
Object.entries(CENTRES).forEach(([id, { lat, lng }]) => {
|
Object.entries(CENTRES).forEach(([id, { lat, lng }]) => {
|
||||||
@@ -79,9 +87,6 @@ export default function Map({ state }) {
|
|||||||
|
|
||||||
const currentId = WAYPOINT_IDS[Math.min(chapter - 1, 4)]
|
const currentId = WAYPOINT_IDS[Math.min(chapter - 1, 4)]
|
||||||
|
|
||||||
const europePath = makePath(EUROPE)
|
|
||||||
const africaPath = makePath(AFRICA)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-screen">
|
<div className="map-screen">
|
||||||
<svg
|
<svg
|
||||||
@@ -89,17 +94,42 @@ export default function Map({ state }) {
|
|||||||
style={{ width: '100%', maxWidth: W, display: 'block', margin: '0 auto' }}
|
style={{ width: '100%', maxWidth: W, display: 'block', margin: '0 auto' }}
|
||||||
aria-label="Mediterranean trade map"
|
aria-label="Mediterranean trade map"
|
||||||
>
|
>
|
||||||
{/* Sea */}
|
{/* Sea — permanent darkness beyond fog of war */}
|
||||||
<rect width={W} height={H} fill="#1a2a3a" />
|
<rect width={W} height={H} fill="#0d1a26" />
|
||||||
|
|
||||||
{/* Land — two polygons, no cross-sea lines */}
|
{/* Fog of war — land H7 cells, revealed by chapter */}
|
||||||
<path d={europePath} fill="#2d3b2a" stroke="#4a5a3a" strokeWidth="1" />
|
{WAYPOINT_IDS.map((id) => {
|
||||||
<path d={africaPath} fill="#2d3b2a" stroke="#4a5a3a" strokeWidth="1" />
|
const wp = WAYPOINTS[id]
|
||||||
|
if (chapter < wp.chapter) return null
|
||||||
|
const cells = cellData[id]
|
||||||
|
if (!cells) return null
|
||||||
|
return cells.map((cell, cellIdx) => {
|
||||||
|
if (!cell.is_land) return null
|
||||||
|
const pos = h7ApproxPos(id, cellIdx)
|
||||||
|
if (!pos) return null
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`${id}-${cell.h7}`}
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={H7_RADIUS}
|
||||||
|
fill="#2d3b2a"
|
||||||
|
stroke="#3a4a30"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Routes */}
|
{/* Routes — visible only between revealed waypoints */}
|
||||||
{ROUTE_ORDER.map((routeId) => {
|
{ROUTE_ORDER.map((routeId) => {
|
||||||
const route = ROUTES.find((r) => r.id === routeId)
|
const route = ROUTES.find((r) => r.id === routeId)
|
||||||
if (!route) return null
|
if (!route) return null
|
||||||
|
// Route visible when both endpoints are revealed
|
||||||
|
const fromWp = WAYPOINTS[route.from]
|
||||||
|
const toWp = WAYPOINTS[route.to]
|
||||||
|
if (chapter < fromWp.chapter || chapter < toWp.chapter) return null
|
||||||
const a = pts[route.from]
|
const a = pts[route.from]
|
||||||
const b = pts[route.to]
|
const b = pts[route.to]
|
||||||
const unlocked = chapter > route.chapter
|
const unlocked = chapter > route.chapter
|
||||||
@@ -108,17 +138,18 @@ export default function Map({ state }) {
|
|||||||
key={routeId}
|
key={routeId}
|
||||||
x1={a.x} y1={a.y}
|
x1={a.x} y1={a.y}
|
||||||
x2={b.x} y2={b.y}
|
x2={b.x} y2={b.y}
|
||||||
stroke={unlocked ? '#c8a96e' : '#4a5a6a'}
|
stroke={unlocked ? '#c8a96e' : '#2a3a4a'}
|
||||||
strokeWidth={unlocked ? 1.5 : 1}
|
strokeWidth={unlocked ? 1.5 : 0.5}
|
||||||
strokeDasharray={unlocked ? undefined : '4 4'}
|
strokeDasharray={unlocked ? undefined : '3 5'}
|
||||||
opacity={unlocked ? 0.8 : 0.35}
|
opacity={unlocked ? 0.8 : 0.2}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Waypoints */}
|
{/* Waypoints — only revealed cities visible */}
|
||||||
{WAYPOINT_IDS.map((id) => {
|
{WAYPOINT_IDS.map((id) => {
|
||||||
const wp = WAYPOINTS[id]
|
const wp = WAYPOINTS[id]
|
||||||
|
if (chapter < wp.chapter) return null
|
||||||
const { x, y } = pts[id]
|
const { x, y } = pts[id]
|
||||||
const isCurrent = id === currentId
|
const isCurrent = id === currentId
|
||||||
const isReached = WAYPOINT_IDS.indexOf(id) < chapter - 1
|
const isReached = WAYPOINT_IDS.indexOf(id) < chapter - 1
|
||||||
|
|||||||
Reference in New Issue
Block a user