client: travel screen — SVG Italy map with clickable city markers

Replace plain list with a styled SVG Italy outline (mainland, Sicily,
Sardinia). Cities rendered as markers: color by risk level, pulse
animation for current city,  icon for active events. Clicking a
marker or a list row opens a travel modal with city details and
confirm button. List remains as compact fallback below the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:11:53 +02:00
parent 25376553e7
commit bf5cfdb4ed
+118 -20
View File
@@ -1,32 +1,63 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/api'; import { api } from '../api/api';
import { ApiError } from '../api/http'; import { ApiError } from '../api/http';
import type { City } from '../api/types'; import type { City, WorldEvent } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { formatMoney, riskLabel } from '../shared/format'; import { formatMoney, riskLabel } from '../shared/format';
/** Sagoma stilizzata dell'Italia (coordinate 0-100, stesse di City.mapX/mapY). */
const ITALY_MAINLAND =
'M12,18 L18,11 L30,9 L44,8 L50,13 L54,20 L60,28 L70,40 L82,46 L91,52 L88,57 L79,58 ' +
'L71,63 L65,71 L63,79 L58,76 L57,67 L52,58 L45,46 L37,34 L25,27 L15,24 Z';
const ITALY_SICILY = 'M44,83 L54,81 L62,84 L58,91 L48,93 L43,88 Z';
const ITALY_SARDINIA = 'M13,55 L21,56 L22,66 L19,74 L12,72 L11,62 Z';
const RISK_COLORS: Record<string, string> = {
basso: '#4caf6e',
medio: '#d4a017',
alto: '#d9534f',
};
export function TravelScreen() { export function TravelScreen() {
const { player, setPlayer } = useAuth(); const { player, setPlayer } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [cities, setCities] = useState<City[]>([]); const [cities, setCities] = useState<City[]>([]);
const [events, setEvents] = useState<WorldEvent[]>([]);
const [selected, setSelected] = useState<City | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const res = await api.cities(); const [c, e] = await Promise.all([api.cities(), api.events()]);
setCities(res.cities); setCities(c.cities);
setEvents(e.events);
}, []); }, []);
useEffect(() => { useEffect(() => {
void reload(); void reload();
}, [reload]); }, [reload]);
const eventsByCity = useMemo(() => {
const map = new Map<string, WorldEvent[]>();
for (const event of events) {
const key = event.city?.id ?? 'global';
map.set(key, [...(map.get(key) ?? []), event]);
}
return map;
}, [events]);
const cityEvents = (cityId: string): WorldEvent[] => [
...(eventsByCity.get(cityId) ?? []),
...(eventsByCity.get('global') ?? []),
];
async function travelTo(city: City) { async function travelTo(city: City) {
setBusy(true); setBusy(true);
try { try {
const res = await api.travel(city.id); const res = await api.travel(city.id);
setPlayer(res.player); setPlayer(res.player);
toast(`Benvenuto a ${res.cityName} (${formatMoney(res.cost)})`, 'success'); toast(`Benvenuto a ${res.cityName} (${formatMoney(res.cost)})`, 'success');
setSelected(null);
} catch (err) { } catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally { } finally {
@@ -36,34 +67,101 @@ export function TravelScreen() {
return ( return (
<div className="screen"> <div className="screen">
<h2 className="screen__title">Città</h2> <h2 className="screen__title">Mappa</h2>
<div className="map-card">
<svg viewBox="0 0 100 100" className="map-svg" role="img" aria-label="Mappa delle città">
<path d={ITALY_MAINLAND} className="map-land" />
<path d={ITALY_SICILY} className="map-land" />
<path d={ITALY_SARDINIA} className="map-land" />
{cities.map((city) => {
const isCurrent = player?.currentCityId === city.id;
const hasEvent = (eventsByCity.get(city.id) ?? []).length > 0;
const color = RISK_COLORS[riskLabel(city.riskLevel)] ?? '#d4a017';
return (
<g
key={city.id}
className="map-marker"
onClick={() => setSelected(city)}
transform={`translate(${city.mapX}, ${city.mapY})`}
>
{isCurrent && <circle r="4.5" className="map-marker__pulse" fill={color} />}
<circle r="2.6" fill="#0f0f13" stroke={color} strokeWidth="0.8" />
<circle r="1" fill={isCurrent ? '#e8e4da' : color} />
{hasEvent && (
<text x="2.6" y="-2.2" fontSize="3.5">
</text>
)}
<text x="0" y="6.8" textAnchor="middle" className="map-marker__label">
{city.name}
</text>
</g>
);
})}
</svg>
<p className="muted small map-legend">
colore = rischio (verde basso, oro medio, rosso alto) · evento in corso · tocca una
città per viaggiare
</p>
</div>
<ul className="list"> <ul className="list">
{cities.map((city) => { {cities.map((city) => {
const isCurrent = player?.currentCityId === city.id; const isCurrent = player?.currentCityId === city.id;
const canAfford = (player?.money ?? 0) >= city.travelCost;
return ( return (
<li key={city.id} className={`list__row${isCurrent ? ' is-current' : ''}`}> <li
key={city.id}
className={`list__row list__row--clickable${isCurrent ? ' is-current' : ''}`}
onClick={() => setSelected(city)}
>
<div> <div>
<strong>{city.name}</strong> <strong>{city.name}</strong>
{isCurrent && <span className="badge badge--small">sei qui</span>} {isCurrent && <span className="badge badge--small">sei qui</span>}
<p className="muted small"> {(eventsByCity.get(city.id) ?? []).length > 0 && <span> </span>}
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
</p>
</div> </div>
{!isCurrent && ( <span className="muted small">
<button {isCurrent ? '—' : formatMoney(city.travelCost)}
type="button" </span>
className="btn btn--secondary btn--small"
disabled={busy || !canAfford}
onClick={() => void travelTo(city)}
>
Viaggia · {formatMoney(city.travelCost)}
</button>
)}
</li> </li>
); );
})} })}
</ul> </ul>
{selected && (
<div className="modal-backdrop" onClick={() => setSelected(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{selected.name}</h3>
<p className="muted small">
rischio {riskLabel(selected.riskLevel)} · economia ×{selected.economyModifier} ·
polizia ×{selected.policePressure}
</p>
{cityEvents(selected.id).length > 0 && (
<div className="banner">
{cityEvents(selected.id).map((event) => (
<span key={event.id}>
{event.title}
{!event.city && ' (globale)'}
</span>
))}
</div>
)}
{player?.currentCityId === selected.id ? (
<p className="muted">Ti trovi già in questa città.</p>
) : (
<button
type="button"
className="btn btn--primary"
disabled={busy || (player?.money ?? 0) < selected.travelCost}
onClick={() => void travelTo(selected)}
>
Viaggia · {formatMoney(selected.travelCost)}
</button>
)}
</div>
</div>
)}
</div> </div>
); );
} }