This commit is contained in:
Kelorastus
2026-09-25 10:14:44 +02:00
parent 4d620fc5ad
commit 21d6e6bffb
34 changed files with 615 additions and 321 deletions
+15
View File
@@ -238,6 +238,17 @@
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
{#if s?.map_from_site === false}
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
<div class="notice" role="status">
<Icon name="alert" size={14} />
<p class="selectable">
{site}'s map service is unreachable: drawing from this PC's own tiles.
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
</p>
</div>
{/if}
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
<Icon name="stop" size={12} />
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
@@ -509,6 +520,10 @@
color: var(--danger);
}
.error-box p,
.notice .dim {
color: var(--muted);
}
.notice p {
flex: 1;
color: var(--ink-2);
+2 -12
View File
@@ -5,8 +5,9 @@
</script>
<div class="hero">
<!-- No wordmark here: the key art behind this panel carries ODIAN's own, and two
of them read as a mistake. -->
<p class="eyebrow tagline">Tactical map console</p>
<h1 class="wordmark">ODIAN</h1>
{#if status}
<p class="meta mono">
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
@@ -40,17 +41,6 @@
background: var(--accent);
}
.wordmark {
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
color: var(--ink);
font-family: var(--font-display);
font-size: clamp(52px, 5.4vw, 72px);
font-weight: 600;
line-height: 1;
letter-spacing: var(--wordmark-tracking);
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
}
.meta {
display: flex;
flex-wrap: wrap;
+43 -258
View File
@@ -1,252 +1,16 @@
<script lang="ts">
// Original key art, generated here: an orthographic wireframe globe with a
// bearing ring, a slow orbit, a few track markers and faint topographic
// contours. Static except for one orbiting point (off with reduced motion).
import { prefersReducedMotion } from "svelte/motion";
const W = 1280;
const H = 760;
const CX = 930;
const CY = 392;
const R = 288;
const TILT = rad(24); // view latitude
const SPIN = rad(-20); // view longitude
function rad(deg: number) {
return (deg * Math.PI) / 180;
}
interface Pt {
x: number;
y: number;
visible: boolean;
}
function project(latDeg: number, lonDeg: number): Pt {
const phi = rad(latDeg);
const lambda = rad(lonDeg) - SPIN;
const cosPhi = Math.cos(phi);
const x = R * cosPhi * Math.sin(lambda);
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
}
const fmt = (n: number) => n.toFixed(1);
/** Splits a projected polyline into its near-side and far-side runs. */
function trace(points: Pt[]) {
let front = "";
let back = "";
let prev: boolean | null = null;
for (const p of points) {
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
if (p.visible) front += seg;
else back += seg;
prev = p.visible;
}
return { front, back };
}
function range(from: number, to: number, step: number) {
const out: number[] = [];
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
return out;
}
const lines = [
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
];
const gridFront = lines.map((l) => l.front).join("");
const gridBack = lines.map((l) => l.back).join("");
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
// Bearing ring around the globe.
const RING = R + 26;
const ticks = range(0, 355, 5)
.map((deg) => {
const a = rad(deg - 90);
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
const c = Math.cos(a);
const s = Math.sin(a);
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
})
.join("");
// Labels in the upper-left quadrant would sit under the hero wordmark.
const ringLabels = range(0, 330, 30)
.filter((deg) => deg < 285 || deg > 345)
.map((deg) => {
const a = rad(deg - 90);
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
});
// Track markers joined by great-circle legs.
type Vec = [number, number, number];
const toVec = (lat: number, lon: number): Vec => [
Math.cos(rad(lat)) * Math.cos(rad(lon)),
Math.cos(rad(lat)) * Math.sin(rad(lon)),
Math.sin(rad(lat)),
];
function leg(a: [number, number], b: [number, number]) {
const va = toVec(...a);
const vb = toVec(...b);
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
const pts: Pt[] = [];
for (let i = 0; i <= 48; i++) {
const t = i / 48;
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
const kb = Math.sin(t * omega) / Math.sin(omega);
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
const lat = (Math.asin(v[2]) * 180) / Math.PI;
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
pts.push(project(lat, lon));
}
return trace(pts).front;
}
const waypoints: { lat: number; lon: number; label: string }[] = [
{ lat: 14, lon: -36, label: "WPT K7" },
{ lat: 33, lon: -12, label: "TRK 1187" },
{ lat: 47, lon: 5, label: "TRK 0412" },
];
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
const route =
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
const ORBIT_TILT = -14;
const RX = R * 1.42;
const RY = R * 0.34;
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
// Topographic contours: nested wobbly rings.
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
return range(0, count - 1, 1).map((i) => {
const pts = range(0, 120, 1).map((k) => {
const th = (k / 120) * Math.PI * 2;
const r =
28 +
i * 26 +
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
4 * Math.sin(2 * th + i * 0.5);
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
});
return `M${pts.join("L")}Z`;
});
}
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
// The launcher's backdrop: ODIAN's own key art, the picture the product is
// presented with. It replaces the globe this file used to draw in SVG.
//
// It starts where the news panel ends, so the wordmark is never half-hidden
// behind it and the panel's side of the window stays plain black.
import keyart from "../assets/keyart.webp";
</script>
<div class="keyart" aria-hidden="true">
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
</radialGradient>
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#172241" />
<stop offset="0.55" stop-color="#0d1019" />
<stop offset="1" stop-color="#09090b" />
</radialGradient>
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
<stop offset="0" stop-color="#fff" stop-opacity="1" />
<stop offset="1" stop-color="#fff" stop-opacity="0" />
</radialGradient>
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
<rect width="1" height="1" fill="#4a4a52" />
</pattern>
<mask id="ka-dots-mask">
<rect width={W} height={H} fill="url(#ka-fade)" />
</mask>
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
<circle cx={CX} cy={CY} r={R} fill="#000" />
</mask>
<path id="ka-orbit" d={orbitPath} />
</defs>
<rect width={W} height={H} fill="#09090b" />
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
{#each topo as d, i (i)}
<path {d} />
{/each}
</g>
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
<g stroke="#9a999e" fill="none">
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
<path d={ticks} stroke-opacity="0.22" />
</g>
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
{#each ringLabels as l (l.text)}
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
</g>
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
<g class="markers">
{#each markers as m (m.label)}
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
</g>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitFront} stroke-opacity="0.34" />
</g>
</svg>
<!-- Separate layer so the static art above never repaints. -->
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
{#if prefersReducedMotion.current}
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
{:else}
<g mask="url(#ka-behind)">
<g>
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
</g>
<g opacity="0">
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
{/if}
</g>
</svg>
<div class="frame">
<img class="art" src={keyart} alt="" draggable="false" />
</div>
<div class="vignette"></div>
</div>
@@ -260,27 +24,48 @@
pointer-events: none;
}
.layer {
/* `left` is where the hub's first column ends: its padding plus the column's
width, kept in step with App.svelte's grid (and its 1140px breakpoint). The
frame is what crops the picture - an <img> left to itself takes its own
aspect ratio, whatever the box says, and `object-position` then has nothing
to slide. The left edge fades in, so the black beside it is a backdrop
rather than a seam. */
.frame {
position: absolute;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 432px;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
}
/* Cover, then slide: this picks the whole wordmark and the near half of the
globe, with the mark starting just clear of the panel. */
.art {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% center;
user-select: none;
}
.ring-labels,
.markers {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
}
/* Only the edges, where the title bar and the status bar sit: the wordmark and
the globe are the point, and a left-hand wash would swallow them. */
.vignette {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%),
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%),
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%),
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%);
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 28%),
linear-gradient(180deg, rgba(9, 9, 11, 0.6) 0%, rgba(9, 9, 11, 0) 16%),
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
}
@media (max-width: 1140px) {
.frame {
left: 384px;
}
}
</style>
+22 -2
View File
@@ -105,10 +105,23 @@
}
}
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
const drawnBySite = (item: MarketItem) =>
item.presence === "absent" && drive === null && (s?.online ?? false);
function presenceLabel(item: MarketItem): string {
if (item.presence === "installed") return "Installed";
if (item.presence === "partial") return "Partly downloaded";
return "Not installed";
// "Not installed" would read as missing when nothing is missing: while the DHP
// answers it draws this itself. What this PC lacks is the copy that works offline.
return drawnBySite(item) ? "On the DHP" : "Not installed";
}
function presenceHelp(item: MarketItem): string | undefined {
if (item.presence !== "absent" || drive !== null) return undefined;
return drawnBySite(item)
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
: "Not on this PC, and your DHP is out of reach.";
}
function changeNote(item: MarketItem): string | null {
@@ -246,7 +259,9 @@
</span>
{#if item.description}<span class="desc">{item.description}</span>{/if}
<span class="meta">
<span class="pill {item.presence}">{presenceLabel(item)}</span>
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
{presenceLabel(item)}
</span>
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
</span>
</span>
@@ -532,6 +547,11 @@
background: var(--warn-soft);
color: var(--warn);
}
/* Drawn by the DHP: a working state, not a warning. */
.pill.from-site {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
}
.change {
color: var(--accent-hover);
font-size: var(--text-xs);
+27 -2
View File
@@ -10,7 +10,7 @@
let { open = $bindable(false) }: Props = $props();
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
let dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY });
@@ -31,20 +31,26 @@
const trimmed = $derived<Settings>({
site_url: draft.site_url.trim(),
mcp_url: draft.mcp_url.trim(),
ai_url: draft.ai_url.trim(),
install_dir: draft.install_dir.trim(),
});
const dirty = $derived(
current !== null &&
(trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url ||
trimmed.ai_url !== current.ai_url ||
trimmed.install_dir !== current.install_dir),
);
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
const siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url));
/** Optional: an empty AI address is how an install with no line out is left. */
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive);
const invalid = $derived(
siteError !== null || mcpError !== null || aiError !== null || dirError !== null || offDrive,
);
function urlProblem(value: string): string | null {
if (!value) return "Required.";
@@ -199,6 +205,25 @@
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
</span>
</div>
<div class="field">
<label class="field-label" for="set-ai-url">AI URL</label>
<input
id="set-ai-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
placeholder="https://engine.odian.fr"
bind:value={draft.ai_url}
aria-invalid={dirty && aiError !== null}
aria-describedby="set-ai-url-help"
/>
<span id="set-ai-url-help" class={dirty && aiError ? "field-error" : "field-help"}>
{dirty && aiError
? aiError
: "Destination prediction, chat and vision, served over the internet. Leave empty and ODIAN runs wholly offline."}
</span>
</div>
</fieldset>
<fieldset class="group" disabled={locked || saving}>
+6 -6
View File
@@ -2,6 +2,8 @@
import { onMount } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
// ODIAN's own mark, the same one the window and the taskbar carry.
import mark from "../assets/mark.png";
interface Props {
onOpenSettings: () => void;
@@ -44,11 +46,7 @@
<header class="titlebar" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region>
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
</svg>
<img class="mark" src={mark} width="18" height="18" alt="" draggable="false" data-tauri-drag-region />
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
<span class="product" data-tauri-drag-region>Launcher</span>
</div>
@@ -130,7 +128,9 @@
}
.mark {
color: var(--accent);
display: block;
border-radius: 4px;
user-select: none;
}
.wordmark {