Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/superpowers/specs/2026-07-26-real-world-map-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# nuke — real world map for the Action screen

**Date:** 2026-07-26
**Status:** approved in brainstorming (visual companion); pending spec review
**Goal:** Replace the Action screen's abstract blob continents with a recognisable real-world map (real coastlines), leaders pinned at true geographic locations, and Freedonia as an invented island in the mid-Atlantic. Confirmed visually in the companion.

## 0. Decisions (locked)

- **Real coastlines, comic-styled.** Actual Natural Earth 110m land (public domain), simplified, drawn in the existing ink-outline / paper-fill / halftone-ocean style — recognisable Earth, not photoreal, not toddler blobs.
- **Real geographic pins.** Every leader sits at true lon/lat via an equirectangular mapping (`x = lon + 180`, `y = 90 - lat`). Freedonia = invented mid-Atlantic island (~40°W, 32°N). Human slots 2–5 take ocean positions.
- **Presentational only.** No engine/gameplay change. Overlays (launch arcs, impact rings, pulses, diplomacy trails, build icons) keep their behaviour — only their coordinate constants rescale to the new map units.

## 1. Asset & provenance (already in the branch)

- `src/ui/components/worldPath.ts` — `WORLD_PATH` (single `d`, ~37KB, `fill-rule="evenodd"`) + `WORLD_VIEWBOX = "0 12 360 132"`. Sourced from Natural Earth 110m Land (public domain: "No permission is needed… crediting unnecessary"), converted to `x=lon+180, y=90-lat`, RDP-simplified. Licence/provenance noted in the file header.
- `scripts/worldmap/ne_110m_land.equirect.svg` (source) + `scripts/worldmap/convert.py` (regenerator) kept for reproducibility.

## 2. WorldMap.tsx changes (`src/ui/components/WorldMap.tsx`)

### 2.1 Coordinate system
- viewBox: `"0 0 100 85"` → `WORLD_VIEWBOX` (`"0 12 360 132"`).
- `preserveAspectRatio`: `"none"` → `"xMidYMid meet"` (real coastlines must keep their proportions, not stretch).
- Add an ocean background covering the full equirectangular extent so letterboxing (from `meet`) reads as ocean: a `<rect x="0" y="0" width="360" height="180">` in the ocean cyan + the existing halftone dot pattern, drawn first.
- Continents: replace the seven hand-authored `<path>`s (+ the separate UK island + its label) with one `<path d={WORLD_PATH} fillRule="evenodd" fill="var(--paper)" stroke="var(--ink)" strokeWidth="0.5" strokeLinejoin="round" />`. (UK is now part of the real coastline, so the special-case island path AND the standalone "UK" continent label are removed; UK keeps its country pin like everyone else.)

### 2.2 COUNTRY_POS → real lon/lat
Rewrite via an `ll(lon, lat)` helper (`{ cx: lon + 180, cy: 90 - lat }`). Values (lon, lat):
- chump USA (-98, 39) · carnage CANADA (-106, 58) · burnem UK (-2, 54) · netanyahoo ISR (34, 31) · khameneverhere IRAN (53, 32) · mileigh-hem ARG (-64, -38)
- player1 Freedonia (-40, 32) — mid-Atlantic
- player2 (-150, 5) Pacific · player3 (78, -28) Indian Ocean · player4 (170, 42) N Pacific · player5 (-25, -45) S Atlantic
Keep the `CountryPos { cx, cy, label? }` shape and AI `label`s (USA/CANADA/UK/ISR/IRAN/ARG) so the render loop is unchanged.

### 2.3 Freedonia island
Freedonia has no real landmass. Draw a small invented island under player1's pin (a `<path>` blob in `--cyan-soft`, ~6×5 units) so the human isn't floating on open ocean. Only for player1 (and only if player1 is in the cast); other human ocean slots just get their pin/marker (islandless is fine for them, or reuse the same small-island blob — implementer's call, keep it cheap).

### 2.4 Rescale overlay + marker constants to the 360×132 space
The map units are ~3.6× the old 100-wide space; `meet` keeps units square so one factor applies. Use these (validated in the companion mock):
- Country marker ellipse: `rx` ~5–6 (UK/ISR smaller ~3.5), `ry` ~3–3.4; label `fontSize` ~3.6 (small pins ~3.2). Drop the old `isUK` special-case sizing if no longer needed, or keep a "small pin" set for UK/ISR which sit close together in the Middle East / NW Europe.
- LaunchArc apex: `midY = min(a.cy,b.cy) - 16` (was −18 in the small space); strokeWidths ~1.0 (yellow) / 0.5 (magenta); flight-icon fontSize ~8.
- ImpactRings: outer `rx 13 / ry 8`, inner `rx 16.5 / ry 10` (scaled from 9/5 & 11.5/7); strokeWidths ~0.8 / 0.4.
- Pulse: `r` ~5–6, strokeWidth ~0.5.
- Trail: mid offset ~−10, strokeWidth ~0.5, icon fontSize ~5.
- Build-icon: fontSize ~5, offset ~−7.
Tune by eye against the running app; the companion mock's arc (Iran→Freedonia) already reads correctly at these values.

## 3. CSS (`WorldMap.module.css` / Action)
- Ensure the map container background is the ocean cyan (or the SVG's own ocean rect fully covers the frame) so `meet` letterbox bars, if any, don't show as ink. Minimal change — likely just confirm `.svg` fills its box and the ocean rect covers the extent. No layout restructure.

## 4. Testing (`tests/ui/WorldMap.test.tsx`)
- Update assertions tied to the old viewBox / abstract paths / UK special-casing.
- Assert: the real `WORLD_PATH` renders (one big path with `fill-rule="evenodd"`); each cast leader renders a marker + label at its `COUNTRY_POS`; an actor gets the green highlight and a receiver the magenta (existing behaviour) at the new coords; a launch event still renders a `LaunchArc` + `ImpactRings` (overlay wiring intact). Assertions unconditional.
- `npm run typecheck` clean; `npm run test:run` green.

## 5. Out of scope
- Engine/gameplay, other screens, animation timing (unchanged).
- Per-continent interactivity, zoom/pan, real-time projection maths (static equirectangular only).
- Multi-human island art beyond a shared cheap blob.

## 6. Constraints
- Product emojis kept. Unconditional test assertions. Every commit typechecks + suite green.
- Keep the Natural Earth licence/provenance comment in `worldPath.ts` intact.
- Deterministic, presentational — no RNG.
88 changes: 88 additions & 0 deletions scripts/worldmap/convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import json

d = json.load(open('ne110m.json'))

def rdp(points, epsilon):
if len(points) < 3:
return points
(x1, y1) = points[0]
(x2, y2) = points[-1]
dx, dy = x2 - x1, y2 - y1
norm = (dx * dx + dy * dy) ** 0.5
dmax = 0.0
idx = 0
for i in range(1, len(points) - 1):
(x0, y0) = points[i]
if norm == 0:
d = ((x0 - x1) ** 2 + (y0 - y1) ** 2) ** 0.5
else:
d = abs(dy * x0 - dx * y0 + x2 * y1 - y2 * x1) / norm
if d > dmax:
idx = i
dmax = d
if dmax > epsilon:
left = rdp(points[:idx + 1], epsilon)
right = rdp(points[idx:], epsilon)
return left[:-1] + right
else:
return [points[0], points[-1]]

def ring_to_pts(ring):
pts = [(lon + 180.0, 90.0 - lat) for lon, lat in ring]
cleaned = [pts[0]]
for p in pts[1:]:
if p != cleaned[-1]:
cleaned.append(p)
return cleaned

def fmt(v):
return f"{v:g}"

def pts_to_path(pts, epsilon):
if len(pts) < 3:
return ""
if pts[0] != pts[-1]:
pts = pts + [pts[0]]
simplified = rdp(pts, epsilon)
simplified = [(round(x, 1), round(y, 1)) for x, y in simplified]
if len(simplified) < 4:
return ""
s = "M" + f"{fmt(simplified[0][0])},{fmt(simplified[0][1])}"
for p in simplified[1:]:
s += f"L{fmt(p[0])},{fmt(p[1])}"
s += "Z"
return s

EPS = 0.15
MIN_RING_BBOX = 0.0 # degrees^2 area threshold to drop tiny islands; 0 = keep all

parts = []
dropped = 0
for feat in d['features']:
geom = feat['geometry']
polys = [geom['coordinates']] if geom['type'] == 'Polygon' else geom['coordinates']
for poly in polys:
for ring in poly:
pts = ring_to_pts(ring)
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
bbox_area = (max(xs)-min(xs)) * (max(ys)-min(ys))
if bbox_area < MIN_RING_BBOX:
dropped += 1
continue
p = pts_to_path(pts, EPS)
if p:
parts.append(p)

path_d = "".join(parts)
print("epsilon:", EPS, "num rings kept:", len(parts), "dropped:", dropped)
print("path length (chars):", len(path_d))

svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 180">
<path d="{path_d}" fill="#4a7c59" fill-rule="evenodd" stroke="none"/>
</svg>
'''

with open('worldsrc.svg', 'w') as f:
f.write(svg)

print("svg byte size:", len(svg.encode('utf-8')))
3 changes: 3 additions & 0 deletions scripts/worldmap/ne_110m_land.equirect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 68 additions & 48 deletions src/ui/components/WorldMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { GameState, LeaderId, ResolutionEvent } from '../../engine/types';
import { isHuman } from '../../engine/state';
import { HUMAN_ACCENTS, PORTRAIT_META, stripFlag } from '../portraits';
import { formatEventText } from '../util/eventText';
import { WORLD_PATH, WORLD_VIEWBOX } from './worldPath';
import styles from './WorldMap.module.css';

export interface CountryPos {
Expand All @@ -11,23 +12,29 @@ export interface CountryPos {
label?: string;
}

/** Map positions — AI values verbatim from the handoff (screens-3.jsx);
* human slots 2–5 are fixed ocean positions (slice-3 spec §5.2). */
/** Equirectangular screen coords from real lon/lat: x = lon + 180, y = 90 - lat. */
const ll = (lon: number, lat: number): { cx: number; cy: number } => ({ cx: lon + 180, cy: 90 - lat });

/** Leaders pinned at true geographic locations; Freedonia is an invented
* mid-Atlantic island and the other human slots take open-ocean spots. */
export const COUNTRY_POS: Record<LeaderId, CountryPos> = {
chump: { cx: 23, cy: 36, label: 'USA' },
carnage: { cx: 24, cy: 24, label: 'CANADA' },
burnem: { cx: 44, cy: 13, label: 'UK' },
netanyahoo: { cx: 58, cy: 40, label: 'ISR' },
khameneverhere: { cx: 64, cy: 38, label: 'IRAN' },
'mileigh-hem': { cx: 33, cy: 78, label: 'ARG' },
player1: { cx: 22, cy: 50 },
player2: { cx: 10, cy: 62 },
player3: { cx: 84, cy: 18 },
player4: { cx: 90, cy: 68 },
player5: { cx: 44, cy: 66 },
chump: { ...ll(-98, 39), label: 'USA' },
carnage: { ...ll(-106, 58), label: 'CANADA' },
burnem: { ...ll(-2, 54), label: 'UK' },
netanyahoo: { ...ll(34, 31), label: 'ISR' },
khameneverhere: { ...ll(53, 32), label: 'IRAN' },
'mileigh-hem': { ...ll(-64, -38), label: 'ARG' },
player1: ll(-40, 32), // Freedonia — mid-Atlantic
player2: ll(-150, 5), // Pacific
player3: ll(78, -28), // Indian Ocean
player4: ll(170, 42), // North Pacific
player5: ll(-25, -45), // South Atlantic
};

/** The event's actor (green highlight), per slice-3 spec §5.3. */
/** Small pins for countries that are geographically small / crowded together. */
const SMALL_PIN = new Set<LeaderId>(['burnem', 'netanyahoo']);

/** The event's actor (green highlight). */
export function actorOf(event: ResolutionEvent): LeaderId | undefined {
switch (event.kind) {
case 'FactoryBuilt':
Expand All @@ -49,7 +56,7 @@ export function actorOf(event: ResolutionEvent): LeaderId | undefined {
}
}

/** The event's receivers (magenta highlights), per slice-3 spec §5.3. */
/** The event's receivers (magenta highlights). */
export function receiversOf(event: ResolutionEvent): LeaderId[] {
switch (event.kind) {
case 'PropagandaTransfer':
Expand All @@ -71,7 +78,7 @@ export function receiversOf(event: ResolutionEvent): LeaderId[] {

function launchArcPath(a: CountryPos, b: CountryPos): { d: string; midX: number; midY: number } {
const midX = (a.cx + b.cx) / 2;
const midY = Math.min(a.cy, b.cy) - 18;
const midY = Math.min(a.cy, b.cy) - 16;
return { d: `M ${a.cx} ${a.cy} Q ${midX} ${midY} ${b.cx} ${b.cy}`, midX, midY };
}

Expand All @@ -80,9 +87,9 @@ function LaunchArc({ a, b, icon }: { a: CountryPos; b: CountryPos; icon: string
const { d, midX, midY } = launchArcPath(a, b);
return (
<g>
<path d={d} fill="none" stroke="var(--yellow)" strokeWidth="0.6" strokeDasharray="1.5 1" />
<path d={d} fill="none" stroke="var(--magenta)" strokeWidth="0.3" />
<text x={midX} y={midY + 4} fontSize="5" textAnchor="middle">{icon}</text>
<path d={d} fill="none" stroke="var(--yellow)" strokeWidth="1" strokeDasharray="3 2" />
<path d={d} fill="none" stroke="var(--magenta)" strokeWidth="0.5" />
<text x={midX} y={midY + 3} fontSize="8" textAnchor="middle">{icon}</text>
</g>
);
}
Expand All @@ -91,25 +98,25 @@ function LaunchArc({ a, b, icon }: { a: CountryPos; b: CountryPos; icon: string
function ImpactRings({ p }: { p: CountryPos }) {
return (
<g>
<ellipse cx={p.cx} cy={p.cy} rx="9" ry="5" fill="none" stroke="var(--magenta)" strokeWidth="0.6" strokeDasharray="1 0.8" opacity="0.85" />
<ellipse cx={p.cx} cy={p.cy} rx="11.5" ry="7" fill="none" stroke="var(--magenta)" strokeWidth="0.3" strokeDasharray="0.8 1.2" opacity="0.5" />
<ellipse cx={p.cx} cy={p.cy} rx="13" ry="8" fill="none" stroke="var(--magenta)" strokeWidth="0.8" strokeDasharray="2 1.5" opacity="0.85" />
<ellipse cx={p.cx} cy={p.cy} rx="17" ry="10.5" fill="none" stroke="var(--magenta)" strokeWidth="0.4" strokeDasharray="1.5 2" opacity="0.5" />
</g>
);
}

/** Green firing pulse circle. */
function Pulse({ p }: { p: CountryPos }) {
return <circle cx={p.cx} cy={p.cy} r="5" fill="none" stroke="var(--green)" strokeWidth="0.4" />;
return <circle cx={p.cx} cy={p.cy} r="6" fill="none" stroke="var(--green)" strokeWidth="0.5" />;
}

/** Cyan/green dashed diplomacy trail with an icon at the midpoint. */
function Trail({ a, b, stroke, icon }: { a: CountryPos; b: CountryPos; stroke: string; icon: string }) {
const midX = (a.cx + b.cx) / 2;
const midY = (a.cy + b.cy) / 2 - 6;
const midY = (a.cy + b.cy) / 2 - 10;
return (
<g>
<path d={`M ${a.cx} ${a.cy} Q ${midX} ${midY} ${b.cx} ${b.cy}`} fill="none" stroke={stroke} strokeWidth="0.4" strokeDasharray="0.8 0.6" />
<text x={midX} y={midY + 1.5} fontSize="3.6" textAnchor="middle">{icon}</text>
<path d={`M ${a.cx} ${a.cy} Q ${midX} ${midY} ${b.cx} ${b.cy}`} fill="none" stroke={stroke} strokeWidth="0.5" strokeDasharray="2 1.5" />
<text x={midX} y={midY + 2.5} fontSize="5" textAnchor="middle">{icon}</text>
</g>
);
}
Expand Down Expand Up @@ -152,7 +159,7 @@ function Overlay({ event, game }: { event: ResolutionEvent; game: GameState }) {
const p = COUNTRY_POS[event.by];
const icon = formatEventText(event, game, 1)?.icon ?? '⚙';
return (
<text className={styles.buildIcon} x={p.cx} y={p.cy - 5} fontSize="4" textAnchor="middle">
<text className={styles.buildIcon} x={p.cx} y={p.cy - 7} fontSize="5" textAnchor="middle">
{icon}
</text>
);
Expand Down Expand Up @@ -182,28 +189,39 @@ export interface WorldMapProps {
event?: ResolutionEvent;
}

/** Stylised political-cartoon world map (slice-3 spec §5).
* Pure presentational: continents, cast country highlights, per-event overlays. */
/** Recognisable real-world map (Natural Earth 110m land, public domain), comic-styled.
* Pure presentational: coastlines, cast country pins at true lon/lat, per-event overlays. */
export default function WorldMap({ game, event }: WorldMapProps) {
const actor = event ? actorOf(event) : undefined;
const receivers = event ? receiversOf(event) : [];
const freedonia = COUNTRY_POS.player1;

return (
<svg className={styles.svg} viewBox="0 0 100 85" preserveAspectRatio="none" aria-hidden>
{/* continents — abstract chunky political-cartoon shapes (verbatim from the handoff) */}
<g fill="var(--paper)" stroke="var(--ink)" strokeWidth="0.4">
<path d="M 5,18 Q 12,12 22,14 Q 32,12 36,22 Q 40,32 30,40 Q 18,44 10,38 Q 2,30 5,18 Z" />
<path d="M 18,46 Q 26,44 32,50 Q 36,60 30,70 Q 24,80 18,76 Q 14,68 16,58 Q 16,50 18,46 Z" />
<path d="M 38,18 Q 46,16 52,22 Q 56,30 50,34 Q 42,32 38,26 Z" />
<path d="M 52,22 Q 60,20 66,24 Q 72,28 70,34 Q 64,38 56,36 Q 50,30 52,22 Z" />
<path d="M 48,32 Q 58,32 64,40 Q 66,48 58,54 Q 50,52 46,44 Q 44,36 48,32 Z" />
<path d="M 62,40 Q 74,42 78,46 L 82,52 Q 78,60 70,58 Q 64,52 62,40 Z" />
{/* UK — separate island northwest of Europe */}
<path d="M 39,9 Q 46,7 49,12 Q 50,18 44,19 Q 38,17 39,9 Z" />
<text x="44" y="14" fill="var(--ink)" fontFamily="Anton, sans-serif" fontSize="2.2" textAnchor="middle" letterSpacing="0.3" opacity="0.55">UK</text>
</g>

{/* country highlights — cast members only */}
<svg className={styles.svg} viewBox={WORLD_VIEWBOX} preserveAspectRatio="xMidYMid meet" aria-hidden>
<defs>
<pattern id="worldOceanDots" width="5" height="5" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.6" fill="rgba(255,255,255,0.16)" />
</pattern>
</defs>
{/* ocean — covers the full equirectangular extent so meet-letterboxing reads as sea */}
<rect x="0" y="0" width="360" height="180" fill="var(--cyan)" />
<rect x="0" y="0" width="360" height="180" fill="url(#worldOceanDots)" />

{/* land — real coastlines, one path (evenodd handles rings) */}
<path d={WORLD_PATH} fillRule="evenodd" fill="var(--paper)" stroke="var(--ink)" strokeWidth="0.5" strokeLinejoin="round" />

{/* Freedonia — invented mid-Atlantic island under the player's pin */}
{game.cast.includes('player1') && (
<path
d={`M ${freedonia.cx - 6} ${freedonia.cy} Q ${freedonia.cx - 3} ${freedonia.cy - 5} ${freedonia.cx} ${freedonia.cy - 4} Q ${freedonia.cx + 6} ${freedonia.cy - 4} ${freedonia.cx + 6} ${freedonia.cy + 1} Q ${freedonia.cx + 3} ${freedonia.cy + 5} ${freedonia.cx - 2} ${freedonia.cy + 4} Z`}
fill="var(--cyan-soft)"
stroke="var(--ink)"
strokeWidth="0.5"
strokeLinejoin="round"
/>
)}

{/* country pins — cast members only */}
{game.cast.map((id) => {
const p = COUNTRY_POS[id];
const isActor = actor === id;
Expand All @@ -216,23 +234,25 @@ export default function WorldMap({ game, event }: WorldMapProps) {
? HUMAN_ACCENTS[id]
: PORTRAIT_META[id].color;
const label = p.label ?? stripFlag(game.leaders[id].country).toUpperCase();
const isUK = id === 'burnem';
const small = SMALL_PIN.has(id);
const rx = small ? 4 : 7;
const ry = small ? 2.6 : 4;
return (
<g key={id} data-leader={id}>
<ellipse
cx={p.cx}
cy={p.cy}
rx={isUK ? 3 : 6}
ry={isUK ? 2 : 3.4}
rx={rx}
ry={ry}
fill={fill}
opacity={isActor || isReceiver ? 0.85 : 0.5}
/>
<text
x={p.cx}
y={p.cy + (isUK ? 4 : 6)}
y={p.cy + ry + 3.4}
fill="var(--ink)"
fontFamily="Anton, sans-serif"
fontSize={isUK ? 2 : 2.4}
fontSize={small ? 3.2 : 3.6}
textAnchor="middle"
letterSpacing="0.2"
>
Expand Down
12 changes: 12 additions & 0 deletions src/ui/components/worldPath.ts

Large diffs are not rendered by default.

Loading
Loading