diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts index affc94e1add..c4395e66b0c 100644 --- a/apps/desktop/src/app/starmap/geometry.ts +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -89,9 +89,18 @@ export function fitViewport(w: number, h: number, outer: number = RING_OUTER): V return { k: 1, x: w / 2, y: h / 2 } } - const spanX = (outer + 30) * 2 - const spanY = spanX * TILT - const k = clamp(Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / spanY, 2.2), ZOOM_MIN, ZOOM_MAX) + // Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in). + const kFor = (r: number): number => { + const spanX = (r + 30) * 2 + + return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2) + } + + // Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map + // renders at that constant scale and overflows — you pan it — instead of + // shrinking every node to fit. Smaller extents (few rings, or the playback + // core) still fit tightly / zoom in. + const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX) // Bias the center down a touch — the timeline along the top adds visual weight // up there, so true-center reads as sitting high. @@ -107,7 +116,8 @@ export function radiusForRecency(rec: number, outer: number = RING_OUTER): numbe // Screen-space scale at the graph's fully-rested fit. Nodes size against THIS, // not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how // big they read (billboarded), while a full-map view keeps its honest density. -export const fitScale = (w: number, h: number, rings: Ring[]): number => fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k +export const fitScale = (w: number, h: number, rings: Ring[]): number => + fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k // Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts index e529b72a5b4..12e26eab171 100644 --- a/apps/desktop/src/app/starmap/render.ts +++ b/apps/desktop/src/app/starmap/render.ts @@ -80,8 +80,7 @@ const NODE_BIRTH = { down: 0.11, up: 0.075 } // Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus // a few digits/symbols for the "digital rain / decoding" look. -const SCRAMBLE_CHARS = - 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' +const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' // Sphere-sprite atlas: a lit orb is the same picture at every size, so we render // each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and @@ -477,6 +476,7 @@ export function drawScene(scene: Scene): DrawResult { if (revealed) { revealedRings.add(n.outerRingIndex) } + const isFocus = revealed && n.id === focusId const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi @@ -758,7 +758,7 @@ export function drawScramble({ rings: Ring[] vp: Viewport }): void { - const { darkTheme, primary } = palette + const { bg, darkTheme, primary } = palette const projX = (wx: number) => wx * vp.k + vp.x const projY = (wy: number) => wy * vp.k * TILT + vp.y @@ -766,14 +766,33 @@ export function drawScramble({ const coreX = projX(0) const coreY = projY(0) - // Fill to the innermost ring (the core shell), not the RING_INNER constant — - // the ring sits in lead-in space, so derive the radius from it directly. - const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94 + // Scale with the world (like the rings), but ~1.25× bigger than the bare inner + // shell so the core reads prominently at the rested fit. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25 if (coreRx <= 0) { return } + // Backdrop wash: a background-colour radial dimming the core ellipse, so on a + // busy map the nodes/links crowding through the centre recede behind the orb. + // Self-masking — bg over empty bg is invisible, so a sparse map shows no disc. + const washR = coreRx * 1.15 + ctx.save() + ctx.translate(coreX, coreY) + ctx.scale(1, TILT) + const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR) + // Near-opaque across the core (busy graph effectively vanishes behind the orb) + // with a soft falloff only at the rim so there's no hard disc edge. + wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93)) + wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88)) + wash.addColorStop(1, rgba(bg, 0)) + ctx.fillStyle = wash + ctx.beginPath() + ctx.arc(0, 0, washR, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + // Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the // glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more, // smaller glyphs rather than a few giant ones — and stays legible when tiny. diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx index a782b405a8e..45c9bcc0f1b 100644 --- a/apps/desktop/src/app/starmap/share-controls.tsx +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -1,11 +1,17 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' import { useI18n } from '@/i18n' +import { Upload } from '@/lib/icons' interface ShareControlsProps { // True when the shown map was loaded from a pasted code (not the live scan). @@ -17,20 +23,20 @@ interface ShareControlsProps { shareCode?: string } -const SECTION_LABEL = 'text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/55' - -// WoW-talent-loadout style sharing: one icon button opens a popover with the -// current map's code (copy/export) and a paste box (import) — drop a string, -// see the build. Lives bottom-right of the map, mirroring the legend. +// Share / import a map as a single code. The textarea shows the current map's +// code (copy it to share); edit/replace it and hit Load to view someone else's. +// One field, one button — a standard Dialog matching rename/create. export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) { const { t } = useI18n() const [open, setOpen] = useState(false) - const [draft, setDraft] = useState('') + const [value, setValue] = useState('') const [error, setError] = useState(null) - const apply = () => { - const code = draft.trim() + const own = (shareCode ?? '').trim() + const code = value.trim() + const canLoad = code !== '' && code !== own + const load = () => { if (!code) { setError(t.starmap.importEmpty) @@ -42,97 +48,85 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod if (err === null) { setOpen(false) - setDraft('') } } return ( - { - setOpen(next) - - if (!next) { - setError(null) - } - }} - open={open} - > - +
+ {imported && ( - + )} - -
-
- {t.starmap.share} - {imported && ( - + { + setOpen(next) + setError(null) + + if (next) { + setValue(shareCode ?? '') + } + }} + open={open} + > + + + + + + + {t.starmap.shareTitle} + {t.starmap.shareHint} + + + {/* One code field: pre-filled with this map's code (copy to share); edit + or paste another and Load. Copy button floats in on hover, like a + thread code block. */} +
+