Travel directions, agent resilience, opportunities clarity
- Travel planner shows the compass bearing (N/NE/.../NW) of each hop, computed from world-map coordinates (+x East, +y North, calibrated against Bridgewatch=south, Lymhurst=east). Zone positions parsed from world.bin into new zones.pos_x/pos_y columns. - Agent: retry ingest POSTs with backoff + longer timeout, graceful failure (no more uncaught crash on a transient connect timeout). - Opportunities: state the city prices apply to in the breakdown, and show tier+enchant as one "T4.3" chip with a tooltip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d629d1641
commit
742b7c2085
9 changed files with 189 additions and 37 deletions
|
|
@ -1,29 +1,61 @@
|
||||||
import type { AlbionOrder, ParsedWorld } from '@albion/shared';
|
import type { AlbionOrder, ParsedWorld } from '@albion/shared';
|
||||||
import type { AgentConfig } from './config.js';
|
import type { AgentConfig } from './config.js';
|
||||||
|
|
||||||
/** Thin client for the server's ingest endpoints. */
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin client for the server's ingest endpoints, with retry on transient failures. */
|
||||||
export class ServerClient {
|
export class ServerClient {
|
||||||
constructor(private cfg: AgentConfig) {}
|
constructor(private cfg: AgentConfig) {}
|
||||||
|
|
||||||
private async post(pathname: string, body: unknown): Promise<any> {
|
private async post(pathname: string, body: unknown, retries = 4): Promise<any> {
|
||||||
const res = await fetch(`${this.cfg.serverUrl}${pathname}`, {
|
const payload = JSON.stringify(body);
|
||||||
method: 'POST',
|
let lastErr: unknown;
|
||||||
headers: {
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
'Content-Type': 'application/json',
|
try {
|
||||||
...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}),
|
const res = await fetch(`${this.cfg.serverUrl}${pathname}`, {
|
||||||
},
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
headers: {
|
||||||
signal: AbortSignal.timeout(30000),
|
'Content-Type': 'application/json',
|
||||||
});
|
...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}),
|
||||||
if (!res.ok) {
|
},
|
||||||
const text = await res.text().catch(() => '');
|
body: payload,
|
||||||
throw new Error(`${pathname} -> HTTP ${res.status} ${text}`);
|
signal: AbortSignal.timeout(45000),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new Error('401 unauthorized — check the apiKey in your agent config matches the server.');
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${pathname} -> HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
// don't retry auth failures — the key is wrong, retrying won't help
|
||||||
|
if (e instanceof Error && e.message.startsWith('401')) throw e;
|
||||||
|
if (attempt < retries) {
|
||||||
|
const delay = Math.min(15000, 2000 * 2 ** attempt); // 2s,4s,8s,15s
|
||||||
|
console.warn(` ${pathname} attempt ${attempt + 1} failed (${e instanceof Error ? e.message : e}); retrying in ${delay / 1000}s...`);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.json();
|
throw lastErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
ping(): Promise<any> {
|
async ping(): Promise<any> {
|
||||||
return fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(10000) }).then((r) => r.json());
|
// a couple of quick attempts, since the server/NPM may still be warming up
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.cfg.serverUrl}/api/health`, { signal: AbortSignal.timeout(15000) });
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
if (attempt < 2) await sleep(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWorld(world: ParsedWorld): Promise<any> {
|
sendWorld(world: ParsedWorld): Promise<any> {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,12 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === 'world') {
|
if (command === 'world') {
|
||||||
await uploadWorld(client, cfg);
|
try {
|
||||||
|
await uploadWorld(client, cfg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('World upload failed:', e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,4 +145,7 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void main();
|
main().catch((e) => {
|
||||||
|
console.error('Agent error:', e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,13 @@ CREATE TABLE IF NOT EXISTS zones (
|
||||||
albion_id TEXT PRIMARY KEY,
|
albion_id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
color TEXT NOT NULL
|
color TEXT NOT NULL,
|
||||||
|
pos_x DOUBLE PRECISION,
|
||||||
|
pos_y DOUBLE PRECISION
|
||||||
);
|
);
|
||||||
|
-- add position columns to zones tables created before this feature
|
||||||
|
ALTER TABLE zones ADD COLUMN IF NOT EXISTS pos_x DOUBLE PRECISION;
|
||||||
|
ALTER TABLE zones ADD COLUMN IF NOT EXISTS pos_y DOUBLE PRECISION;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS zone_connections (
|
CREATE TABLE IF NOT EXISTS zone_connections (
|
||||||
zone_a TEXT NOT NULL,
|
zone_a TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,12 @@ export async function storeWorld(world: ParsedWorld, source: string): Promise<Wo
|
||||||
for (const [a, b] of world.connections) edgeSet.add([a, b].sort().join('|'));
|
for (const [a, b] of world.connections) edgeSet.add([a, b].sort().join('|'));
|
||||||
|
|
||||||
// chunked bulk inserts
|
// chunked bulk inserts
|
||||||
const zoneRows = world.zones.map((z) => [z.albionId, z.name, z.type, z.color]);
|
const zoneRows = world.zones.map((z) => [z.albionId, z.name, z.type, z.color, z.x, z.y]);
|
||||||
for (let i = 0; i < zoneRows.length; i += 5000) {
|
for (let i = 0; i < zoneRows.length; i += 5000) {
|
||||||
const chunk = zoneRows.slice(i, i + 5000);
|
const chunk = zoneRows.slice(i, i + 5000);
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`);
|
const groups = chunk.map((r) => `(${r.map((v) => `$${params.push(v)}`).join(', ')})`);
|
||||||
await tx.query(`INSERT INTO zones (albion_id, name, type, color) VALUES ${groups.join(', ')} ON CONFLICT (albion_id) DO NOTHING`, params);
|
await tx.query(`INSERT INTO zones (albion_id, name, type, color, pos_x, pos_y) VALUES ${groups.join(', ')} ON CONFLICT (albion_id) DO NOTHING`, params);
|
||||||
}
|
}
|
||||||
const edges = [...edgeSet].map((e) => e.split('|'));
|
const edges = [...edgeSet].map((e) => e.split('|'));
|
||||||
for (let i = 0; i < edges.length; i += 10000) {
|
for (let i = 0; i < edges.length; i += 10000) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,20 @@ export interface Zone {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
x: number | null;
|
||||||
|
y: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 8-point compass bearing from A to B using map coords (+x East, +y North). */
|
||||||
|
const COMPASS = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE'];
|
||||||
|
export function direction(a: Zone, b: Zone): string | null {
|
||||||
|
if (a.x == null || a.y == null || b.x == null || b.y == null) return null;
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
if (dx === 0 && dy === 0) return null;
|
||||||
|
const angle = (Math.atan2(dy, dx) * 180) / Math.PI; // 0 = East, 90 = North
|
||||||
|
const idx = ((Math.round(angle / 45) % 8) + 8) % 8;
|
||||||
|
return COMPASS[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TravelMode = 'safe' | 'all';
|
export type TravelMode = 'safe' | 'all';
|
||||||
|
|
@ -20,7 +34,7 @@ const ROUTABLE_COLORS = new Set(['city', 'blue', 'yellow', 'red', 'black', 'othe
|
||||||
async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
|
async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string, string[]> }> {
|
||||||
const zones = new Map<string, Zone>();
|
const zones = new Map<string, Zone>();
|
||||||
for (const r of (await query('SELECT * FROM zones')) as any[]) {
|
for (const r of (await query('SELECT * FROM zones')) as any[]) {
|
||||||
zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color });
|
zones.set(r.albion_id, { albionId: r.albion_id, name: r.name, type: r.type, color: r.color, x: r.pos_x, y: r.pos_y });
|
||||||
}
|
}
|
||||||
const adj = new Map<string, string[]>();
|
const adj = new Map<string, string[]>();
|
||||||
for (const r of (await query('SELECT * FROM zone_connections')) as any[]) {
|
for (const r of (await query('SELECT * FROM zone_connections')) as any[]) {
|
||||||
|
|
@ -32,9 +46,14 @@ async function loadGraph(): Promise<{ zones: Map<string, Zone>; adj: Map<string,
|
||||||
return { zones, adj };
|
return { zones, adj };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RouteStep extends Zone {
|
||||||
|
/** compass direction of the NEXT hop from this zone (null on the final zone) */
|
||||||
|
dirToNext: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteResult {
|
export interface RouteResult {
|
||||||
found: boolean;
|
found: boolean;
|
||||||
path: Zone[];
|
path: RouteStep[];
|
||||||
zonesTraversed: number;
|
zonesTraversed: number;
|
||||||
dangerZones: number; // red + black on the path
|
dangerZones: number; // red + black on the path
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +96,14 @@ export async function findRoute(fromName: string, toName: string, mode: TravelMo
|
||||||
|
|
||||||
if (!prev.has(goal)) return { found: false, path: [], zonesTraversed: 0, dangerZones: 0 };
|
if (!prev.has(goal)) return { found: false, path: [], zonesTraversed: 0, dangerZones: 0 };
|
||||||
|
|
||||||
const path: Zone[] = [];
|
const zonePath: Zone[] = [];
|
||||||
for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) {
|
for (let cur: string | null = goal; cur != null; cur = prev.get(cur) ?? null) {
|
||||||
path.unshift(zones.get(cur)!);
|
zonePath.unshift(zones.get(cur)!);
|
||||||
}
|
}
|
||||||
|
const path: RouteStep[] = zonePath.map((z, i) => ({
|
||||||
|
...z,
|
||||||
|
dirToNext: i < zonePath.length - 1 ? direction(z, zonePath[i + 1]) : null,
|
||||||
|
}));
|
||||||
const dangerZones = path.filter((z) => z.color === 'red' || z.color === 'black').length;
|
const dangerZones = path.filter((z) => z.color === 'red' || z.color === 'black').length;
|
||||||
return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
|
return { found: true, path, zonesTraversed: path.length - 1, dangerZones };
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +114,8 @@ export async function listZones(): Promise<Zone[]> {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
color: r.color,
|
color: r.color,
|
||||||
|
x: r.pos_x,
|
||||||
|
y: r.pos_y,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ export interface WorldZone {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** world-map coordinates (+x = East, +y = North); null if the zone has none */
|
||||||
|
x: number | null;
|
||||||
|
y: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedWorld {
|
export interface ParsedWorld {
|
||||||
|
|
@ -65,9 +68,18 @@ interface RawCluster {
|
||||||
'@id'?: string;
|
'@id'?: string;
|
||||||
'@displayname'?: string;
|
'@displayname'?: string;
|
||||||
'@type'?: string;
|
'@type'?: string;
|
||||||
|
'@worldmapposition'?: string;
|
||||||
exits?: { exit?: RawExit | RawExit[] };
|
exits?: { exit?: RawExit | RawExit[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse "x y" world-map position; returns [null, null] when absent/malformed. */
|
||||||
|
function parsePos(raw: string | undefined): [number | null, number | null] {
|
||||||
|
if (!raw) return [null, null];
|
||||||
|
const parts = raw.trim().split(/\s+/).map(Number);
|
||||||
|
if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return [null, null];
|
||||||
|
return [parts[0], parts[1]];
|
||||||
|
}
|
||||||
|
|
||||||
function asArray<T>(v: T | T[] | undefined): T[] {
|
function asArray<T>(v: T | T[] | undefined): T[] {
|
||||||
if (v === undefined || v === null) return [];
|
if (v === undefined || v === null) return [];
|
||||||
return Array.isArray(v) ? v : [v];
|
return Array.isArray(v) ? v : [v];
|
||||||
|
|
@ -129,12 +141,17 @@ export function parseWorldXml(xml: string): ParsedWorld {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zones: zones.map((z) => ({
|
zones: zones.map((z) => {
|
||||||
albionId: z['@id']!,
|
const [x, y] = parsePos(z['@worldmapposition']);
|
||||||
name: z['@displayname']!,
|
return {
|
||||||
type: z['@type'] ?? '',
|
albionId: z['@id']!,
|
||||||
color: zoneColor(z['@type'] ?? ''),
|
name: z['@displayname']!,
|
||||||
})),
|
type: z['@type'] ?? '',
|
||||||
|
color: zoneColor(z['@type'] ?? ''),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
};
|
||||||
|
}),
|
||||||
connections: [...edges].map((e) => e.split('|') as [string, string]),
|
connections: [...edges].map((e) => e.split('|') as [string, string]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
web/src/apiCache.ts
Normal file
55
web/src/apiCache.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Tiny in-memory client cache so switching tabs doesn't refetch data that's
|
||||||
|
* still fresh. Keyed by an arbitrary string; entries expire after a TTL. Pages
|
||||||
|
* call cachedJson() on mount (instant when warm) and pass force:true from a
|
||||||
|
* manual Refresh button to bypass and repopulate.
|
||||||
|
*
|
||||||
|
* Lives at module scope, so it survives component unmount/remount (tab switches)
|
||||||
|
* but resets on a full page reload.
|
||||||
|
*/
|
||||||
|
interface Entry {
|
||||||
|
at: number;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Entry>();
|
||||||
|
const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export interface CachedOptions {
|
||||||
|
ttl?: number;
|
||||||
|
force?: boolean;
|
||||||
|
init?: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cachedJson<T = any>(key: string, url: string, opts: CachedOptions = {}): Promise<T> {
|
||||||
|
const ttl = opts.ttl ?? DEFAULT_TTL;
|
||||||
|
const hit = store.get(key);
|
||||||
|
if (!opts.force && hit && Date.now() - hit.at < ttl) {
|
||||||
|
return hit.data as T;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, opts.init);
|
||||||
|
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as T;
|
||||||
|
store.set(key, { at: Date.now(), data });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peek a cached value without fetching (for instant first paint). */
|
||||||
|
export function peek<T = any>(key: string): T | undefined {
|
||||||
|
return store.get(key)?.data as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How old the cached entry is, in ms (undefined if absent). */
|
||||||
|
export function ageMs(key: string): number | undefined {
|
||||||
|
const hit = store.get(key);
|
||||||
|
return hit ? Date.now() - hit.at : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop cache entries whose key starts with prefix (or everything if omitted). */
|
||||||
|
export function invalidate(prefix?: string): void {
|
||||||
|
if (!prefix) {
|
||||||
|
store.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
|
||||||
|
}
|
||||||
|
|
@ -163,8 +163,9 @@ export default function OpportunitiesPage() {
|
||||||
<tr key={o.recipeId} className="clickable" onClick={() => setExpanded(expanded === o.recipeId ? null : o.recipeId)}>
|
<tr key={o.recipeId} className="clickable" onClick={() => setExpanded(expanded === o.recipeId ? null : o.recipeId)}>
|
||||||
<td>
|
<td>
|
||||||
{o.outputName ?? o.outputItem}
|
{o.outputName ?? o.outputItem}
|
||||||
{o.enchantment > 0 && <span className="tag">.{o.enchantment}</span>}
|
<span className="tag" title={o.enchantment > 0 ? `Tier ${o.tier}, enchantment ${o.enchantment}` : `Tier ${o.tier}`}>
|
||||||
<span className="tag">T{o.tier}</span>
|
T{o.tier}{o.enchantment > 0 ? `.${o.enchantment}` : ''}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{o.source === 'upgrade' ? 'enchant' : 'craft'}</td>
|
<td className="muted">{o.source === 'upgrade' ? 'enchant' : 'craft'}</td>
|
||||||
<td style={{ color: o.profit >= 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)}</td>
|
<td style={{ color: o.profit >= 0 ? '#7fd18a' : '#e08869', fontWeight: 600 }}>{silver(o.profit)}</td>
|
||||||
|
|
@ -180,7 +181,8 @@ export default function OpportunitiesPage() {
|
||||||
<td colSpan={9} style={{ background: '#191d24' }}>
|
<td colSpan={9} style={{ background: '#191d24' }}>
|
||||||
<div style={{ padding: '8px 4px' }}>
|
<div style={{ padding: '8px 4px' }}>
|
||||||
<p className="muted" style={{ margin: '4px 0' }}>
|
<p className="muted" style={{ margin: '4px 0' }}>
|
||||||
Sells for {silver(o.outputPrice)} ({o.amountCrafted}× per craft) · station fee {silver(o.fee)} ·
|
Sells in <strong>{o.city}</strong> for {silver(o.outputPrice)} ({o.amountCrafted}× per craft) ·
|
||||||
|
inputs bought in {o.city} · station fee {silver(o.fee)} ·
|
||||||
{o.focusUsed > 0 ? ` uses ${Math.round(o.focusUsed)} focus ·` : ''} return rate {(o.rrr * 100).toFixed(1)}%
|
{o.focusUsed > 0 ? ` uses ${Math.round(o.focusUsed)} focus ·` : ''} return rate {(o.rrr * 100).toFixed(1)}%
|
||||||
</p>
|
</p>
|
||||||
<table>
|
<table>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ interface Zone {
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RouteStep extends Zone {
|
||||||
|
dirToNext: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteResult {
|
interface RouteResult {
|
||||||
found: boolean;
|
found: boolean;
|
||||||
path: Zone[];
|
path: RouteStep[];
|
||||||
zonesTraversed: number;
|
zonesTraversed: number;
|
||||||
dangerZones: number;
|
dangerZones: number;
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +170,11 @@ export default function TravelPage() {
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
{route.path.map((z, i) => (
|
{route.path.map((z, i) => (
|
||||||
<span key={z.albionId} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
<span key={z.albionId} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
{i > 0 && <span className="muted">›</span>}
|
{i > 0 && (
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
{route.path[i - 1].dirToNext ? `— ${route.path[i - 1].dirToNext} →` : '›'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="tag" style={{ color: ZONE_COLORS[z.color] ?? undefined, fontSize: 13 }}>{z.name}</span>
|
<span className="tag" style={{ color: ZONE_COLORS[z.color] ?? undefined, fontSize: 13 }}>{z.name}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue