diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ff09c8..600c79a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,6 @@ jobs: - name: Build run: npm run build + + - name: Bundle size + run: npm run size diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c36c323..0098240 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -20,7 +20,14 @@ no react-three-fiber. UI primitives under `components/ui/` are generated shadcn/Base UI files and are excluded from linting. The entry point is `index.html` → `app/main.tsx` → `app/page.tsx`. The 3D engine is imported dynamically inside `app/viewer.tsx`, so three.js lands in its own chunk and the -interface paints before it arrives. +interface paints before it arrives. That only holds while nothing the +interface imports eagerly reaches a module that imports three: a single +`import { hasAirflow } from '@/lib/airflow'` once pulled the whole engine into +the entry chunk (1.2 MB, 340 KB gzipped) without any error. Questions the +interface asks before the engine loads live in three-free modules +(`airflow-policy.ts`, `deep-link.ts`, `pointer-intent.ts`, +`inventory-labels.ts`), and `npm run size`, run in CI after the build, fails +if three.js reappears in the entry chunk or it grows past its budget. ## Run it @@ -35,6 +42,7 @@ npm run lint # oxlint npm run format # oxfmt npm test # node --experimental-strip-types --test tests/*.test.ts npm run build # tsc --noEmit && vite build +npm run size # after a build: entry chunk under budget, no three.js in it npm start # preview the production build ``` @@ -174,7 +182,11 @@ subsystems stop touching. | `lib/graphics-card.ts`, `card-kit.ts`, `radeon-card.ts`, `arc-card.ts` | The three graphics cards. `card-kit.ts` holds the millimetre-scale parts all three share. | | `lib/gpu-architecture.ts`, `radeon-architecture.ts`, `arc-architecture.ts` | The chip block diagrams. | | `lib/diagram-kit.ts` | The shared visual language of those diagrams — `put`, `backdrop`, `block` — with a palette per chip. Navi 48 and BMG-G21 draw from it; the GB202 scales predate it and build their own scenery. | -| `lib/airflow.ts` | Where the air goes: the chevron, the sampling and marching of a path, `cardAirflow` for the three cards, `airflowStrength`, the curve that takes it off screen as the machine opens, and `airflowShown`, which decides whether a scale draws it at all. | +| `lib/airflow.ts` | Where the air goes: the chevron, the sampling and marching of a path, `cardAirflow` for the three cards, and `airflowStrength`, the curve that takes it off screen as the machine opens. Re-exports the policy below so builders keep one import. | +| `lib/airflow-policy.ts` | `airflowLevels`, `hasAirflow`, `AirflowMode` and `airflowShown`, which decides whether a scale draws air at all. No three.js, so the interface can import it. | +| `lib/deep-link.ts` | A place (scale, described part, disassembly) to and from the address bar's query string, and the page title for it. Unknown or contradictory links fall back rather than guess. | +| `lib/pointer-intent.ts` | `tapIntent`: a mouse click opens, a tap describes first. | +| `lib/inventory-labels.ts` | Which laid-out parts get a name on screen: biggest first, one per part, no overlaps, nothing off the stage. | | `lib/parts.ts` | Shared realistic parts: fans, grilles, fin stacks, capacitors, chokes, slots, ports, screws, `glowMaterial`, `buildLightStrip`. | | `lib/materials.ts` | The ten surface finishes and `temperedGlass`. | | `lib/surfaces.ts`, `pcb.ts`, `silicon-texture.ts` | Generated material maps. No downloaded textures. | @@ -188,15 +200,19 @@ subsystems stop touching. | `lib/layout.ts` | `inventoryLayout`, `hardwareInventory`, `spatialInventory`, `smoothstep`, `Vec3`. | | `app/page.tsx` | Chooses the explorer or comparison workbench and otherwise keeps application-level composition state. | | `app/comparison-workbench.tsx`, `comparison-viewer.tsx`, `comparison-specs.tsx` | Comparison composition and controls, the React-to-Three bridge, and the responsive specification sheet. | -| `app/use-explorer.ts` | All explorer state and every named move: navigate, dive, arrive, choose, isolate, hide, and the rest. | +| `app/use-explorer.ts` | All explorer state and every named move: navigate, goTo, dive, arrive, choose, isolate, hide, and the rest. The first state is read from the address. | +| `app/use-address.ts` | Keeps the address bar, title and canonical link on the place shown: a scale change pushes history, a selection or slider move replaces it, and the back button moves the explorer. | +| `app/use-first-visit-hint.ts` | The one-time line under the heading that says what clicking or tapping does. | | `app/use-disassembly-playback.ts` | Shared request-animation-frame playback for explorer and comparison disassembly controls. | | `app/topbar.tsx`, `scale-nav.tsx`, `systems-list.tsx`, `stage-heading.tsx`, `stage-tools.tsx`, `disassembly.tsx`, `search-dialog.tsx`, `detail-panel.tsx`, `about-dialog.tsx` | One panel each. | -| `app/viewer.tsx` | The React ↔ three.js bridge: lazy scene load, hover label, error state. | +| `app/viewer.tsx` | The React ↔ three.js bridge: lazy scene load, the loading line, hover label, names on the laid-out parts, error state. | | `app/links.ts` | Destinations used by more than one panel. | | `app/globals.css`, `app/workbench.css` | The visual direction, desktop through phone. | -| `tests/*.test.ts` | 84 tests: catalogue integrity, layout, picking, geometry presence, airflow, the case, the memory dive, and comparison state/data/pane/bounds behaviour. | +| `tests/*.test.ts` | 94 tests: catalogue integrity, layout, picking, geometry presence, airflow, the case, the memory dive, comparison state/data/pane/bounds behaviour, and links, taps, labels and the engine staying out of the entry chunk. | | `scripts/generate-icons.mjs` | Rasterises `public/favicon.svg` into PNG and ICO variants. Uses Playwright and Edge. | | `scripts/generate-reference-index.mjs` | Regenerates `docs/component-references.md` from the catalogue. Run it after changing citations. | +| `scripts/generate-sitemap.mjs`, `sitemap-urls.ts` | Regenerates `public/sitemap.xml`: the site, the guide, every scale and every part, in the link format the explorer reads. Run it after adding scales or parts; a test fails when it falls behind. | +| `scripts/check-bundle-size.mjs` | `npm run size`. Fails if three.js is in the entry chunk or the chunk is over budget. | ## The interfaces you will touch @@ -302,7 +318,7 @@ itself. State that only one panel cares about lives in that panel — which syst is unfolded is inside `systems-list.tsx`, whether the window is fullscreen is inside `stage-tools.tsx`. -Three interaction decisions are settled, and re-opening them has been tried: +These interaction decisions are settled, and re-opening them has been tried: - **The dive is a zoom, not a scene change.** Clicking an explorable component scales the rest of the stage away while the camera closes on it, then swaps in @@ -310,6 +326,16 @@ Three interaction decisions are settled, and re-opening them has been tried: `lib/scene.ts`, not by React timers — timers drifted against the render loop. `diveInto` is the whole record of a dive in flight, so cancelling one is just clearing it. +- **A tap describes; a click opens.** A mouse opens on left-click and + inspects on right-click. A finger has no right button and iOS does not fire + `contextmenu` on a long press, so a tap selects the part and shows the card, + whose "Take apart" button opens it; tapping the selected part again also + opens it where the card leaves it in reach. `tapIntent` is the rule. +- **The address is the place, not the view.** `?scale=`, `&part=` (with + `&n=` for the n-th identical part) and `&open=` for the disassembly. Camera, + hidden parts and the airflow preference stay out: they are how one person is + looking, not where. Scale changes are history steps; selections and slider + moves replace the current entry, the slider only once it has settled. - **The detail panel is suppressed mid-dive.** `selected` is null while `diveInto` is set, or the outer component's panel flashes for a few hundred milliseconds before the deeper scale replaces it. @@ -354,7 +380,9 @@ A component joins the written catalogue to selectable geometry through its id. inventory layout and counting. 3. Add any new reference to `lib/sources.ts` and document it in `SOURCES.md`, then regenerate the index: - `node --experimental-strip-types scripts/generate-reference-index.mjs`. + `node --experimental-strip-types scripts/generate-reference-index.mjs`, + and the sitemap: + `node --experimental-strip-types scripts/generate-sitemap.mjs`. 4. If the concept lives in a new catalogue file, export its array and compose it into `lib/manifest.ts`. 5. Run the checks. The tests reject anonymous geometry, duplicate ids, broken @@ -406,10 +434,11 @@ the model has. 267 concepts are physical, 154 are logical diagrams. ## Tests -84 tests, all through Node's built-in runner. +94 tests, all through Node's built-in runner. -`.github/workflows/ci.yml` runs `npm ci`, type checking, linting, tests, and a -production build on every push and pull request with Node.js 22. +`.github/workflows/ci.yml` runs `npm ci`, type checking, linting, tests, a +production build and the bundle-size check on every push and pull request +with Node.js 22. - `tests/manifest.test.ts` (16) — unique ids, reciprocal parents, no cycles, source resolution, SKU counts against full-chip capacity, search behaviour, @@ -456,6 +485,12 @@ production build on every push and pull request with Node.js 22. its empty centre columns, the package stack with the die face down over the slot, named geometry everywhere, and search reaching every memory scale. +- `tests/discovery.test.ts` (10) — every scale and every part surviving a + round trip through a link, instances and disassembly kept, nonsense links + falling back to the nearest sensible place, titles, the committed sitemap + matching the catalogue, tap versus click, part names never overlapping or + leaving the stage, and the interface's own modules staying free of three.js. + The suite builds every scale, so a geometry regression usually surfaces as a failing assertion rather than a silent visual change. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2e6f86..f7f6d49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,7 @@ npm run check # type check npm run lint # oxlint npm test # Node's built-in test runner npm run build # production build +npm run size # after the build: keeps three.js out of the first download ``` Format with `npm run format`. GitHub Actions runs the same checks on every push diff --git a/README.md b/README.md index d8df538..005d82d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Descending into a part rebuilds it at its own scale with its own timeline, so th ## The interface -The rail on the left carries the scale tree and per-system visibility. The GPU menu holds three cards, the RTX 5090, Radeon RX 9070 XT and Arc B580, each with its own dropdown of scales. The Memory menu holds the installed DDR5 module and a DDR4 module from the same line, so the two generations can be taken apart side by side or compared in the comparison workbench. The Power supply menu lists two TUF Gaming units directly: an 850W Gold with modular sockets and a 750W Bronze with fixed cables. The bar along the bottom is the disassembly timeline, with the **Auto** key at its left-hand end, the named phases above the slider, and a reset on the right. Left-click an explorable component to open it; right-click to inspect it and use the detail, hide, isolate and focus controls. Hidden components remain available from the stage tracker until they are restored, the scale changes, or the explorer is reset. The **Airflow** switch beside the camera views draws the path the air takes, cool where it enters and warm where it leaves, and it fades as soon as the disassembly slider moves. It starts on wherever there are fans except on the complete machine, where the case is closed and the switch is there to turn it on. The corner expand control toggles browser fullscreen. +The rail on the left carries the scale tree and per-system visibility. The GPU menu holds three cards, the RTX 5090, Radeon RX 9070 XT and Arc B580, each with its own dropdown of scales. The Memory menu holds the installed DDR5 module and a DDR4 module from the same line, so the two generations can be taken apart side by side or compared in the comparison workbench. The Power supply menu lists two TUF Gaming units directly: an 850W Gold with modular sockets and a 750W Bronze with fixed cables. The bar along the bottom is the disassembly timeline, with the **Auto** key at its left-hand end, the named phases above the slider, and a reset on the right. Left-click an explorable component to open it; right-click to inspect it and use the detail, hide, isolate and focus controls. On a touch screen, a tap shows the component's card and its **Take apart** button opens it. At 100% every laid-out part carries its name, and tapping a name describes it. Hidden components remain available from the stage tracker until they are restored, the scale changes, or the explorer is reset. The **Airflow** switch beside the camera views draws the path the air takes, cool where it enters and warm where it leaves, and it fades as soon as the disassembly slider moves. It starts on wherever there are fans except on the complete machine, where the case is closed and the switch is there to turn it on. The corner expand control toggles browser fullscreen. The address bar follows you: a link such as `https://pc-anatomy.com/?scale=sm` opens straight on the RTX 5090's streaming multiprocessor, `&part=` describes a part, `&open=` sets the disassembly, and the back button climbs back out. | Workbench | On a phone | | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -104,6 +104,8 @@ The project is entirely static. The production output is written to `dist/` and The public URL is `https://pc-anatomy.com/`. The HTML entry includes a canonical URL, descriptive title, social preview metadata, and WebSite / WebApplication structured data. `public/guide/index.html` is a readable hardware guide that works without JavaScript and links back to the explorer. +Every scale and part has its own address, and `public/sitemap.xml` lists them all; regenerate it with `node --experimental-strip-types scripts/generate-sitemap.mjs` after adding scales or parts. The canonical link follows the place being shown. + Deploy the entire `dist/` directory, including `guide/`, `robots.txt`, `sitemap.xml`, and the icon files. Serve `/guide/` from its own `index.html` before applying any single-page-app fallback. If the hostname changes, update the canonical and social URLs in both HTML pages, the structured data, and the sitemap and robots files together. After deployment, submit `https://pc-anatomy.com/sitemap.xml` in Google Search Console and request indexing of the homepage and guide. Indexing, favicon display, and ranking are decided by Google and may take time after a recrawl. Do not add fabricated ratings or keyword stuffing. diff --git a/app/page.tsx b/app/page.tsx index b638da3..f0d423d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { EyeOff } from 'lucide-react'; import { levelPath } from '@/lib/levels'; import { useExplorer } from './use-explorer'; +import { useFirstVisitHint } from './use-first-visit-hint'; import Viewer from './viewer'; import PerformanceTip from './performance-tip'; import Topbar from './topbar'; @@ -30,6 +31,11 @@ export default function Home() { // structures visible" on screen for the whole of a cold load. [count, setCount] = useState(null); + const { hint, dismissHint } = useFirstVisitHint( + state, + count !== null && count > 0, + ); + /** Jump to a component, closing the palette if that is where it came from. */ const selectResult = useCallback( (id: string) => { @@ -137,19 +143,17 @@ export default function Home() { logical={logical} selected={selected} count={count} + hint={hint} + onDismissHint={dismissHint} onNavigate={navigate} /> - {count === null && ( -
-

Loading components for you…

-
- )} {count === 0 && (
diff --git a/app/stage-heading.tsx b/app/stage-heading.tsx index b3edc74..4cfef44 100644 --- a/app/stage-heading.tsx +++ b/app/stage-heading.tsx @@ -1,5 +1,5 @@ 'use client'; -import { ArrowLeft, ChevronRight } from 'lucide-react'; +import { ArrowLeft, ChevronRight, MousePointerClick, X } from 'lucide-react'; import type { Concept } from '@/lib/manifest'; import { levelPath, levels, type LevelId } from '@/lib/levels'; @@ -10,15 +10,28 @@ type Props = { selected: Concept | null; /** `null` until the viewer has reported for the first time. */ count: number | null; + /** Show the first-visit line about what clicking does. */ + hint?: boolean; + onDismissHint?: () => void; onNavigate: (level: LevelId) => void; }; +/** A tap cannot right-click, so a phone is told the two-tap version. */ +function hintCopy() { + return typeof matchMedia !== 'undefined' && + matchMedia('(pointer: coarse)').matches + ? 'Tap a part to see what it is and take it apart, or press Auto.' + : 'Click a part to open it, right-click to inspect it, or press Auto.'; +} + export default function StageHeading({ level, explode, logical, selected, count, + hint = false, + onDismissHint, onNavigate, }: Props) { const scale = levels[level], @@ -65,6 +78,15 @@ export default function StageHeading({ )}
+ {hint && ( +

+

+ )} {count !== null && ( - - Created by Yoseph + + Created by + Yoseph
diff --git a/app/use-address.ts b/app/use-address.ts new file mode 100644 index 0000000..11480f7 --- /dev/null +++ b/app/use-address.ts @@ -0,0 +1,70 @@ +'use client'; +import { useEffect, useRef } from 'react'; +import type { ExplorerState } from '@/lib/explorer-state'; +import { + placeTitle, + placeToSearch, + searchToPlace, + type Place, +} from '@/lib/deep-link'; + +/** How long the slider has to rest before its position is written down. */ +const SETTLE_MS = 250; + +/** + * Keeps the address bar on the place being shown, and the place on the + * address bar when the back button moves it. + * + * Changing scale is a step you can take back, so it pushes a history entry. + * Selecting a part or moving the slider refines where you already are, so it + * replaces the current entry instead; otherwise backing out of a scale would + * first replay every part you had clicked on it. Slider moves are also + * settled before they are written, because playback changes the position + * every frame and browsers throttle pages that rewrite history that often. + * + * A dive in flight is not a place yet: the address follows once it lands. + */ +export function useAddress(state: ExplorerState, goTo: (place: Place) => void) { + const writtenLevel = useRef(state.level); + const { level, selection, explode, diveInto } = state; + + useEffect(() => { + if (diveInto) return; + const place: Place = { level, selection, explode }; + const search = placeToSearch(place); + document.title = placeTitle(place); + const url = location.pathname + search + location.hash; + setCanonical(location.origin + location.pathname + search); + if (search === location.search) return; + if (level !== writtenLevel.current) { + writtenLevel.current = level; + history.pushState(null, '', url); + return; + } + const settle = setTimeout( + () => history.replaceState(null, '', url), + SETTLE_MS, + ); + return () => clearTimeout(settle); + }, [level, selection, explode, diveInto]); + + useEffect(() => { + const back = () => { + const place = searchToPlace(location.search); + writtenLevel.current = place.level; + goTo(place); + }; + window.addEventListener('popstate', back); + return () => window.removeEventListener('popstate', back); + }, [goTo]); +} + +/** + * Search engines read the canonical link to decide which address a page is. + * `index.html` names the bare site, which would fold every place back into + * the home page, so it follows the place too. + */ +function setCanonical(href: string) { + const link = document.querySelector('link[rel=canonical]'); + if (link && link.href !== href) link.href = href; +} diff --git a/app/use-explorer.ts b/app/use-explorer.ts index 42265b4..3b594e5 100644 --- a/app/use-explorer.ts +++ b/app/use-explorer.ts @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useState } from 'react'; import { byId, categories, openLevel, type Category } from '@/lib/manifest'; -import { airflowShown } from '@/lib/airflow'; +import { airflowShown } from '@/lib/airflow-policy'; import { initialState, selectSearch, @@ -16,7 +16,9 @@ import { submenuRoot, type LevelId, } from '@/lib/levels'; +import { searchToPlace, type Place } from '@/lib/deep-link'; import { useDisassemblyPlayback } from './use-disassembly-playback'; +import { useAddress } from './use-address'; /** Graphics cards whose chip diagrams open directly, without a dive. */ const gpuRoots = new Set(['card', 'rx9070', 'arcb580']); @@ -29,7 +31,13 @@ const gpuRoots = new Set(['card', 'rx9070', 'arcb580']); * writing its own patch of explorer state at the button that triggers it. */ export function useExplorer() { - const [state, setState] = useState(initialState); + // A shared link opens where it points. Read once, before the first render, + // so the viewer never builds the tower only to throw it away. + const [state, setState] = useState(() => + typeof location === 'undefined' + ? initialState + : placeState(initialState, searchToPlace(location.search)), + ); const [layers, setLayers] = useState(false); const [hiddenMenuOpen, setHiddenMenuOpen] = useState(false); // The subsystem whose menu is open. Follows wherever you are unless you @@ -84,6 +92,21 @@ export function useExplorer() { [stopDisassembly], ); + /** Go straight to a place, as a link or the back button asks. */ + const goTo = useCallback( + (place: Place) => { + stopDisassembly(); + setHiddenMenuOpen(false); + setOpenMenu(null); + setOpenSubmenu(null); + setState((s) => placeState(s, place)); + setLayers(false); + }, + [stopDisassembly], + ); + + useAddress(state, goTo); + const reset = useCallback(() => { stopDisassembly(); setHiddenMenuOpen(false); @@ -311,6 +334,7 @@ export function useExplorer() { shownSubmenu, showSubmenu: setOpenSubmenu, navigate, + goTo, reset, dive, arrive, @@ -333,3 +357,23 @@ export function useExplorer() { } export type Explorer = ReturnType; + +/** + * The explorer arriving at a place: the same clean slate navigation gives, + * with the part described and the hardware opened as far as the place says. + */ +function placeState(s: ExplorerState, place: Place): ExplorerState { + return { + ...s, + level: place.level, + selection: place.selection, + explode: place.explode, + diveInto: null, + isolated: false, + visible: [...categories], + hidden: [], + view: 'perspective', + focusRevision: place.selection ? s.focusRevision + 1 : 0, + cameraRevision: s.cameraRevision + 1, + }; +} diff --git a/app/use-first-visit-hint.ts b/app/use-first-visit-hint.ts new file mode 100644 index 0000000..1cceecc --- /dev/null +++ b/app/use-first-visit-hint.ts @@ -0,0 +1,61 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import type { ExplorerState } from '@/lib/explorer-state'; + +const seenKey = 'pc-anatomy-first-visit-hint-v1'; + +type Spot = Pick; + +/** + * One line telling a first-time visitor what the machine does when touched. + * + * The only place that said so was the hover label, which nobody sees until + * they have already found out, and on a phone never. It shows once per + * browser, shortly after the first frame, and goes away for good the moment + * the visitor does any of the things it describes: picks a part, moves the + * slider, changes scale, or closes it. Storage is a convenience; without it + * the hint simply shows again next visit. + */ +export function useFirstVisitHint(state: ExplorerState, ready: boolean) { + // Where the visitor was when the hint appeared; `null` while it is not up. + const [shownAt, setShownAt] = useState(null); + const latest = useRef(state); + useEffect(() => { + latest.current = state; + }); + + useEffect(() => { + if (!ready) return; + try { + if (localStorage.getItem(seenKey)) return; + } catch { + /* Storage blocked: show it anyway. */ + } + const timer = window.setTimeout(() => { + try { + localStorage.setItem(seenKey, '1'); + } catch { + /* Optional persistence. */ + } + const { level, selection, explode } = latest.current; + setShownAt({ level, selection, explode }); + }, 600); + return () => window.clearTimeout(timer); + }, [ready]); + + const moved = + !!shownAt && + (state.level !== shownAt.level || + state.selection !== shownAt.selection || + state.explode !== shownAt.explode); + + // Once gone it stays gone, even if the visitor wanders back to where they + // started. Adjusting state while rendering, as React documents for state + // derived from props, rather than a frame later in an effect. + if (moved) setShownAt(null); + + return { + hint: !!shownAt && !moved, + dismissHint: () => setShownAt(null), + }; +} diff --git a/app/viewer.tsx b/app/viewer.tsx index 207d401..42f4cb7 100644 --- a/app/viewer.tsx +++ b/app/viewer.tsx @@ -1,21 +1,31 @@ 'use client'; import { useState } from 'react'; import type { ExplorerState, Selection } from '@/lib/explorer-state'; +import type { PartLabel } from '@/lib/inventory-labels'; import { useViewerEngine } from './use-viewer-engine'; type Props = { state: ExplorerState; + /** Nothing has been drawn yet. */ + loading: boolean; onSelect: (v: Selection | null, intent: 'open' | 'inspect') => void; onCount: (v: number) => void; /** The scene has cleared the stage around the part being opened. */ onDived: () => void; }; -export default function Viewer({ state, onSelect, onCount, onDived }: Props) { +export default function Viewer({ + state, + loading, + onSelect, + onCount, + onDived, +}: Props) { const [hover, setHover] = useState<{ name: string; x: number; y: number; opens: boolean; } | null>(null); + const [labels, setLabels] = useState([]); const { host, error, ready } = useViewerEngine( state, { onSelect, onCount, onDived }, @@ -26,6 +36,7 @@ export default function Viewer({ state, onSelect, onCount, onDived }: Props) { dived: () => current().onDived(), hover: (name, x, y, opens) => setHover(name ? { name, x, y, opens } : null), + labels: setLabels, error: reportError, }), 'The 3D viewer could not load. Reload to try again.', @@ -36,10 +47,38 @@ export default function Viewer({ state, onSelect, onCount, onDived }: Props) { ref={host} className="viewport" aria-label="Interactive 3D computer model" - /> - {/* The stage carries its own "Loading components for you…" until the - first frame reports in, so there is one message on screen rather than - this one stacked under it. `ready` still gates the error branch. */} + > + {labels.length > 0 && ( +
    + {labels.map((label) => ( +
  • + +
  • + ))} +
+ )} +
+ {/* One message at a time: the loading line until the first frame + reports in, unless the viewer failed, when the error replaces it + rather than being drawn underneath it. */} + {loading && !error && ( +
+

Loading components for you…

+
+ )} {!ready && !error && ( Loading… )} diff --git a/app/workbench.css b/app/workbench.css index b93cbed..fce5b35 100644 --- a/app/workbench.css +++ b/app/workbench.css @@ -527,6 +527,41 @@ a { color: #fcfcfc; letter-spacing: -0.6px; } +.first-visit-hint { + display: flex; + width: max-content; + max-width: min(520px, calc(100vw - 52px)); + align-items: center; + gap: 9px; + margin: 14px 0 0; + padding: 6px 6px 6px 11px; + border: 1px solid #3a4f5c; + border-radius: 6px; + background: #15202a; + color: #cfe3ef; + font-size: 12.5px; + line-height: 1.4; + animation: tip-appear 200ms ease-out; + /* The heading is not selectable; the hint is a control row like it. */ + pointer-events: auto; +} +.first-visit-hint > svg { + flex-shrink: 0; + color: var(--accent-bright); +} +.first-visit-hint > button { + display: grid; + place-items: center; + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 4px; + color: #9fb3c0; +} +.first-visit-hint > button:hover { + background: #22313d; + color: #e8ecf2; +} .stage-meta { display: flex; align-items: center; @@ -588,6 +623,43 @@ a { display: block; outline: none; } +/* Names on the laid-out parts. The list itself lets the stage through; only + the names take a click, so the tray can still be orbited between them. */ +.part-labels { + position: absolute; + inset: 0; + margin: 0; + padding: 0; + list-style: none; + pointer-events: none; + z-index: 2; +} +.part-labels li { + position: absolute; + animation: tip-appear 160ms ease-out; +} +.part-labels button { + height: 22px; + padding: 0 9px; + border: 1px solid #3d4552; + border-radius: 11px; + background: #121519e6; + color: #d9dee5; + font-size: 11.5px; + line-height: 20px; + white-space: nowrap; + pointer-events: auto; + cursor: pointer; +} +.part-labels button:hover, +.part-labels button:focus-visible { + border-color: var(--accent-bright); + color: #fff; +} +.part-labels button[aria-pressed='true'] { + border-color: #eab878; + color: #ffd790; +} .stage-tools { position: absolute; bottom: 164px; @@ -1581,6 +1653,13 @@ a { .topbar { height: 65px; padding: 0 20px; + /* See the 600px rule: from here down the right-hand buttons can be wider + than a third of the bar, so the outer columns size to their contents. */ + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + } + .byline { + justify-self: center; } .brand { font-size: 17px; @@ -1902,6 +1981,11 @@ a { height: 21px; } } +@media (max-width: 400px) { + .byline .byline-prefix { + display: none; + } +} @media (max-width: 359px) { .disassembly-label { display: none; @@ -2532,10 +2616,25 @@ a { } } @media (max-width: 600px) { + /* Three equal-ish thirds stop working once the right-hand buttons are + wider than a third of a phone: they spill left over the byline and + cover "Toggle systems", the only way into the navigation. The outer + columns take what their contents need and the byline gets the rest. */ .topbar { + grid-template-columns: auto minmax(0, 1fr) auto; padding-inline: 12px; gap: 6px; } + .byline { + justify-self: center; + min-width: 0; + max-width: 100%; + overflow: hidden; + } + .byline-text { + overflow: hidden; + text-overflow: ellipsis; + } .brand { gap: 0; } diff --git a/lib/airflow-policy.ts b/lib/airflow-policy.ts new file mode 100644 index 0000000..52fe344 --- /dev/null +++ b/lib/airflow-policy.ts @@ -0,0 +1,50 @@ +import { rootLevel, type LevelId } from './levels.ts'; + +/** + * Where airflow is drawn, without any of the drawing. + * + * The interface needs these answers before the 3D engine has loaded (the + * stage tools label their button from them, the explorer resolves the + * preference on every move), and `airflow.ts` imports three.js at the top. + * Keeping the policy here means asking whether a scale has fans does not + * pull the whole engine into the first bundle. `airflow.ts` re-exports these + * so the builders keep a single import. + */ + +/** The scales with a fan in them, and so with air to draw. */ +export const airflowLevels: readonly LevelId[] = [ + 'pc', + 'fan', + 'cooler', + 'liquid', + 'psu', + 'psubronze', + 'card', + 'rx9070', + 'arcb580', +]; + +export function hasAirflow(level: LevelId) { + return airflowLevels.includes(level); +} + +/** + * Whether the chevrons are drawn. + * + * `auto` is on wherever there are fans, with one exception: the assembled + * machine you arrive on. A sealed tower is the one place the arrows have to + * cross the whole case to say anything, and four streams over a machine + * nobody has touched yet is a lot to meet first — but it is also the one + * place a viewer might never think to look for them, so the control says its + * own name there rather than hiding behind an icon. + * + * `on` and `off` are someone having said, and they hold on every scale for + * the rest of the session. Coming back to the tower having asked to see the + * air and finding it switched off again would be the machine overruling them. + */ +export type AirflowMode = 'auto' | 'on' | 'off'; + +export function airflowShown(mode: AirflowMode, level: LevelId) { + if (!hasAirflow(level)) return false; + return mode === 'auto' ? level !== rootLevel : mode === 'on'; +} diff --git a/lib/airflow.ts b/lib/airflow.ts index 5dfed6d..001c847 100644 --- a/lib/airflow.ts +++ b/lib/airflow.ts @@ -1,7 +1,6 @@ import * as T from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { smoothstep, type Vec3 } from './layout.ts'; -import { rootLevel, type LevelId } from './levels.ts'; /** * Where the air goes. @@ -81,43 +80,12 @@ export function airflowStrength(explodeFraction: number) { return 1 - smoothstep(0.005, 0.085, explodeFraction); } -/** The scales with a fan in them, and so with air to draw. */ -export const airflowLevels: readonly LevelId[] = [ - 'pc', - 'fan', - 'cooler', - 'liquid', - 'psu', - 'psubronze', - 'card', - 'rx9070', - 'arcb580', -]; - -export function hasAirflow(level: LevelId) { - return airflowLevels.includes(level); -} - -/** - * Whether the chevrons are drawn. - * - * `auto` is on wherever there are fans, with one exception: the assembled - * machine you arrive on. A sealed tower is the one place the arrows have to - * cross the whole case to say anything, and four streams over a machine - * nobody has touched yet is a lot to meet first — but it is also the one - * place a viewer might never think to look for them, so the control says its - * own name there rather than hiding behind an icon. - * - * `on` and `off` are someone having said, and they hold on every scale for - * the rest of the session. Coming back to the tower having asked to see the - * air and finding it switched off again would be the machine overruling them. - */ -export type AirflowMode = 'auto' | 'on' | 'off'; - -export function airflowShown(mode: AirflowMode, level: LevelId) { - if (!hasAirflow(level)) return false; - return mode === 'auto' ? level !== rootLevel : mode === 'on'; -} +export { + airflowLevels, + airflowShown, + hasAirflow, + type AirflowMode, +} from './airflow-policy.ts'; /** * One chevron, pointing along +Z, a unit long and centred on its own length. diff --git a/lib/deep-link.ts b/lib/deep-link.ts new file mode 100644 index 0000000..4ee6845 --- /dev/null +++ b/lib/deep-link.ts @@ -0,0 +1,108 @@ +import { byId, isLevelRoot } from './manifest.ts'; +import { isPhysical, levels, rootLevel, type LevelId } from './levels.ts'; +import type { Selection } from './explorer-state.ts'; + +/** + * Where you are, written into the address bar. + * + * Navigation lives in explorer state, so until this the address was always + * the bare site: a link to the RTX 5090's streaming multiprocessor arrived on + * the assembled tower, and the back button left the site rather than climbing + * a scale. A place is the three things that decide what is on screen and are + * worth sharing: the scale, the part described, and how far apart the + * hardware is. Camera angle, hidden parts and the airflow preference are + * deliberately left out; they are how one person is looking, not where. + * + * Query parameters rather than paths, so every place is served by the same + * `index.html` with no rewrite rules, and rather than a hash, so search + * engines treat each place as its own page: + * + * /?scale=sm the SM diagram + * /?scale=motherboard&part=vrm the board with its VRM described + * /?scale=pc&open=48 the tower half taken apart + * /?scale=pc&part=fan&n=3 the third of several identical fans + * + * Anything unknown or contradictory is dropped rather than guessed at: a link + * that names a part on the wrong scale opens the scale without it, and a link + * to a scale that no longer exists opens the machine. + */ + +export type Place = { + level: LevelId; + selection: Selection | null; + explode: number; +}; + +export const homePlace: Place = { + level: rootLevel, + selection: null, + explode: 0, +}; + +/** Whether a part can be described while this scale is on screen. */ +function belongsTo(concept: string, level: LevelId) { + const c = byId[concept]; + if (!c) return false; + // Arriving on a scale describes the thing you opened, which lives one scale + // up; that is the one concept from elsewhere a place may name. + if (concept === levels[level].concept) return true; + return c.level === level && !isLevelRoot(c); +} + +export function placeToSearch(place: Place): string { + const params = new URLSearchParams(); + const atHome = + place.level === rootLevel && !place.selection && place.explode === 0; + if (atHome) return ''; + params.set('scale', place.level); + if (place.selection && belongsTo(place.selection.concept, place.level)) { + params.set('part', place.selection.concept); + // Instances count from one in a link, the way the hover label numbers them. + if (place.selection.instance) + params.set('n', String(place.selection.instance + 1)); + } + const explode = Math.round(place.explode); + if (explode > 0 && isPhysical(place.level)) + params.set('open', String(Math.min(100, explode))); + return '?' + params.toString(); +} + +export function searchToPlace(search: string): Place { + const params = new URLSearchParams(search); + const scale = params.get('scale'); + if (!scale || !Object.prototype.hasOwnProperty.call(levels, scale)) + return homePlace; + const level = scale as LevelId; + + const part = params.get('part'); + const n = Number(params.get('n') ?? '1'); + const instance = Number.isInteger(n) && n >= 1 && n <= 999 ? n - 1 : 0; + const selection = + part && belongsTo(part, level) + ? instance + ? { concept: part, instance } + : { concept: part } + : null; + + const open = Number(params.get('open') ?? '0'); + const explode = + isPhysical(level) && Number.isFinite(open) + ? Math.max(0, Math.min(100, Math.round(open))) + : 0; + + return { level, selection, explode }; +} + +/** The page title for a place: the part when one is described, else the scale. */ +export function placeTitle(place: Place): string { + if (place.level === rootLevel && !place.selection) return 'PC Anatomy'; + const name = place.selection + ? byId[place.selection.concept]?.name + : levels[place.level].name; + return name ? `${name} · PC Anatomy` : 'PC Anatomy'; +} + +/** Same place, ignoring how a selection reached it. */ +export function samePlace(a: Place, b: Place) { + return placeToSearch(a) === placeToSearch(b); +} diff --git a/lib/explorer-state.ts b/lib/explorer-state.ts index db11fa3..6903b83 100644 --- a/lib/explorer-state.ts +++ b/lib/explorer-state.ts @@ -1,7 +1,7 @@ import { byId, isLevelRoot } from './manifest.ts'; import { categories, type Category } from './concept.ts'; import { isPhysical, rootLevel, type LevelId } from './levels.ts'; -import type { AirflowMode } from './airflow.ts'; +import type { AirflowMode } from './airflow-policy.ts'; /** * What the explorer is currently showing, and the moves that change it. diff --git a/lib/inventory-labels.ts b/lib/inventory-labels.ts new file mode 100644 index 0000000..e4e2810 --- /dev/null +++ b/lib/inventory-labels.ts @@ -0,0 +1,92 @@ +/** + * Names on the laid-out parts. + * + * At 100% every part of a scale is spread out on the tray, sized and spaced + * so each can be seen, and none of them said what it was until you hovered + * it. On a phone you could not hover at all. So once the hardware is laid + * out, each part carries its name, and tapping the name describes the part. + * + * The scene measures where each part sits on screen; this decides which of + * those names fit. Bigger parts are named first, since they are what the eye + * lands on, and a name that would overlap one already placed, or run off the + * stage, is left for the hover label instead of piling up. One name per part: + * eight identical memory chips get one label, on the first of them, not eight. + */ + +export type LabelCandidate = { + concept: string; + instance: number; + name: string; + /** Centre of the part on screen, in CSS pixels from the stage's top left. */ + x: number; + y: number; + /** How large the part looks on screen, used only to rank candidates. */ + size: number; +}; + +export type PartLabel = LabelCandidate & { + /** Where the label's box sits, top left, in the same pixels. */ + left: number; + top: number; +}; + +const CHAR_WIDTH = 6.6, + PADDING_X = 18, + HEIGHT = 22, + GAP = 4, + EDGE = 6; + +export function labelWidth(name: string) { + return Math.ceil(name.length * CHAR_WIDTH + PADDING_X); +} + +export function placeLabels( + candidates: readonly LabelCandidate[], + width: number, + height: number, + limit = 40, +): PartLabel[] { + const seen = new Set(); + const placed: PartLabel[] = []; + const boxes: [number, number, number, number][] = []; + const ranked = [...candidates].sort( + (a, b) => b.size - a.size || a.instance - b.instance, + ); + for (const c of ranked) { + if (placed.length >= limit) break; + if (seen.has(c.concept)) continue; + const w = labelWidth(c.name); + const left = Math.round(c.x - w / 2), + top = Math.round(c.y - HEIGHT / 2); + if ( + left < EDGE || + top < EDGE || + left + w > width - EDGE || + top + HEIGHT > height - EDGE + ) + continue; + const box: [number, number, number, number] = [ + left - GAP, + top - GAP, + left + w + GAP, + top + HEIGHT + GAP, + ]; + if ( + boxes.some( + (b) => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1], + ) + ) + continue; + seen.add(c.concept); + boxes.push(box); + placed.push({ ...c, left, top }); + } + return placed; +} + +/** A cheap fingerprint, so the interface only re-renders when labels move. */ +export function labelSignature(labels: readonly PartLabel[]) { + return labels + .map((l) => `${l.concept}:${l.instance}:${l.left}:${l.top}`) + .join('|'); +} diff --git a/lib/pointer-intent.ts b/lib/pointer-intent.ts new file mode 100644 index 0000000..6a69b92 --- /dev/null +++ b/lib/pointer-intent.ts @@ -0,0 +1,26 @@ +import type { Selection } from './explorer-state.ts'; + +/** + * What a click or tap on a part asks for. + * + * A mouse has two buttons, so the left one opens and the right one inspects. + * A finger has one, and a long press does not reliably reach `contextmenu` + * (iOS Safari does not fire it on a canvas), which left the detail card out of + * reach on a phone: every tap on something that opens went straight inside + * it. So a tap inspects first, showing the card, and the card's "Take apart" + * button opens it. Where the card leaves the part in reach (a tablet's side + * panel rather than a phone's sheet), tapping the selected part again opens + * it too. Pens follow touch: they have no dependable second button either. + */ +export function tapIntent( + pointerType: string, + picked: { concept: string; instance: number } | null, + selection: Selection | null, +): 'open' | 'inspect' { + if (pointerType === 'mouse') return 'open'; + return picked && + selection?.concept === picked.concept && + (selection.instance ?? 0) === picked.instance + ? 'open' + : 'inspect'; +} diff --git a/lib/scene.ts b/lib/scene.ts index 81fe759..5c439cf 100644 --- a/lib/scene.ts +++ b/lib/scene.ts @@ -1,6 +1,13 @@ import * as T from 'three'; import { buildModel, refreshBatches, type Piece } from './models'; import { resolvePickNear } from './picking.ts'; +import { tapIntent } from './pointer-intent.ts'; +import { + labelSignature, + placeLabels, + type LabelCandidate, + type PartLabel, +} from './inventory-labels.ts'; import { airflowShown, airflowStrength } from './airflow.ts'; import { byId, openLevel } from './manifest'; import type { ExplorerState, Selection } from './explorer-state.ts'; @@ -43,8 +50,13 @@ export type SceneCallbacks = { hover: (name: string | null, x: number, y: number, opens: boolean) => void; stats: (visible: number) => void; error: (message: string) => void; + /** Names for the laid-out parts, or an empty list when there are none. */ + labels?: (labels: PartLabel[]) => void; }; +/** How far laid out the parts must be before they are named. */ +const LABELS_FROM = 0.97; + export function createViewer( host: HTMLDivElement, initial: ExplorerState, @@ -370,11 +382,58 @@ export function createViewer( if (pointerPosition && activePointer === null && changed) updateHover(pointerPosition); renderer.render(scene, camera); + reportLabels(); host.dataset.drawCalls = String(renderer.info.render.calls); host.dataset.triangles = String(renderer.info.render.triangles); host.dataset.explode = String(Math.round(amount * 100)); return changed; } + let labelsShown = ''; + const projected = new T.Vector3(), + corner = new T.Vector3(), + labelBox = new T.Box3(); + /** + * Name the parts once they are laid out. Measured after the frame is drawn, + * so the names sit where the parts are, and only reported when they move. + */ + function reportLabels() { + if (!callbacks.labels) return; + let next: PartLabel[] = []; + if ( + isPhysical(state.level) && + !dive && + !state.isolated && + amount >= LABELS_FROM && + state.explode >= 100 + ) { + const w = host.clientWidth, + h = host.clientHeight; + const candidates: LabelCandidate[] = []; + for (const p of model.pieces) { + if (!p.visible) continue; + boxFor(p, labelBox); + labelBox.getCenter(projected).project(camera); + if (projected.z > 1) continue; + corner.copy(labelBox.max).project(camera); + candidates.push({ + concept: p.concept, + instance: p.instance, + name: byId[p.concept].shortName, + x: ((projected.x + 1) / 2) * w, + y: ((1 - projected.y) / 2) * h, + size: Math.hypot( + ((corner.x - projected.x) * w) / 2, + ((corner.y - projected.y) * h) / 2, + ), + }); + } + next = placeLabels(candidates, w, h); + } + const signature = labelSignature(next); + if (signature === labelsShown) return; + labelsShown = signature; + callbacks.labels(next); + } const loop = createStageLoop( controls, () => { @@ -439,7 +498,7 @@ export function createViewer( const p = hit(e, e.pointerType === 'touch' ? TOUCH_AIM : MOUSE_AIM); callbacks.select( p ? { concept: p.concept, instance: p.instance } : null, - 'open', + tapIntent(e.pointerType, p, state.selection), ); wake(); } diff --git a/package.json b/package.json index 4f67921..d7a91ac 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "lint": "oxlint", "format": "oxfmt", "check": "tsc --noEmit", - "test": "node --experimental-strip-types --test tests/*.test.ts" + "test": "node --experimental-strip-types --test tests/*.test.ts", + "size": "node scripts/check-bundle-size.mjs" }, "dependencies": { "@base-ui/react": "1.7.0", diff --git a/public/sitemap.xml b/public/sitemap.xml index 0b32196..d73834e 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,4 +2,448 @@ https://pc-anatomy.com/ https://pc-anatomy.com/guide/ + https://pc-anatomy.com/?scale=motherboard + https://pc-anatomy.com/?scale=dimm + https://pc-anatomy.com/?scale=dram + https://pc-anatomy.com/?scale=banks + https://pc-anatomy.com/?scale=bank + https://pc-anatomy.com/?scale=cell + https://pc-anatomy.com/?scale=ddr4 + https://pc-anatomy.com/?scale=ddr4dram + https://pc-anatomy.com/?scale=ddr4banks + https://pc-anatomy.com/?scale=ddr4array + https://pc-anatomy.com/?scale=ddr4cell + https://pc-anatomy.com/?scale=ryzen + https://pc-anatomy.com/?scale=ryzenio + https://pc-anatomy.com/?scale=corei9 + https://pc-anatomy.com/?scale=coreio + https://pc-anatomy.com/?scale=psu + https://pc-anatomy.com/?scale=psubronze + https://pc-anatomy.com/?scale=fan + https://pc-anatomy.com/?scale=cooler + https://pc-anatomy.com/?scale=liquid + https://pc-anatomy.com/?scale=ssd + https://pc-anatomy.com/?scale=nvme + https://pc-anatomy.com/?scale=card + https://pc-anatomy.com/?scale=die + https://pc-anatomy.com/?scale=gpc + https://pc-anatomy.com/?scale=tpc + https://pc-anatomy.com/?scale=sm + https://pc-anatomy.com/?scale=rx9070 + https://pc-anatomy.com/?scale=navi48 + https://pc-anatomy.com/?scale=rxse + https://pc-anatomy.com/?scale=rxwgp + https://pc-anatomy.com/?scale=rxcu + https://pc-anatomy.com/?scale=arcb580 + https://pc-anatomy.com/?scale=bmg + https://pc-anatomy.com/?scale=xeslice + https://pc-anatomy.com/?scale=xecore + https://pc-anatomy.com/?scale=xve + https://pc-anatomy.com/?scale=pc&part=chassis + https://pc-anatomy.com/?scale=pc&part=sidepanel + https://pc-anatomy.com/?scale=pc&part=frontpanel + https://pc-anatomy.com/?scale=pc&part=frontio + https://pc-anatomy.com/?scale=pc&part=dustfilter + https://pc-anatomy.com/?scale=pc&part=toppanel + https://pc-anatomy.com/?scale=pc&part=psushroud + https://pc-anatomy.com/?scale=pc&part=drivecage + https://pc-anatomy.com/?scale=pc&part=boardstandoff + https://pc-anatomy.com/?scale=pc&part=ioshield + https://pc-anatomy.com/?scale=pc&part=motherboard + https://pc-anatomy.com/?scale=pc&part=graphicscard + https://pc-anatomy.com/?scale=pc&part=psu + https://pc-anatomy.com/?scale=pc&part=psucable + https://pc-anatomy.com/?scale=pc&part=cpucooler + https://pc-anatomy.com/?scale=pc&part=casefan + https://pc-anatomy.com/?scale=pc&part=ssd + https://pc-anatomy.com/?scale=motherboard&part=moboboard + https://pc-anatomy.com/?scale=motherboard&part=socket + https://pc-anatomy.com/?scale=motherboard&part=cpu + https://pc-anatomy.com/?scale=motherboard&part=dimmslot + https://pc-anatomy.com/?scale=motherboard&part=ram + https://pc-anatomy.com/?scale=motherboard&part=chipset + https://pc-anatomy.com/?scale=motherboard&part=uefi + https://pc-anatomy.com/?scale=motherboard&part=cmos + https://pc-anatomy.com/?scale=motherboard&part=pcie16 + https://pc-anatomy.com/?scale=motherboard&part=pcie1 + https://pc-anatomy.com/?scale=motherboard&part=m2slot + https://pc-anatomy.com/?scale=motherboard&part=nvme + https://pc-anatomy.com/?scale=motherboard&part=m2heatsink + https://pc-anatomy.com/?scale=motherboard&part=cpuvrm + https://pc-anatomy.com/?scale=motherboard&part=vrmheatsink + https://pc-anatomy.com/?scale=motherboard&part=atx24 + https://pc-anatomy.com/?scale=motherboard&part=eps8 + https://pc-anatomy.com/?scale=motherboard&part=sataport + https://pc-anatomy.com/?scale=motherboard&part=reario + https://pc-anatomy.com/?scale=motherboard&part=lan + https://pc-anatomy.com/?scale=motherboard&part=audiocodec + https://pc-anatomy.com/?scale=motherboard&part=superio + https://pc-anatomy.com/?scale=motherboard&part=mobofanheader + https://pc-anatomy.com/?scale=motherboard&part=frontheader + https://pc-anatomy.com/?scale=dimm&part=dimmboard + https://pc-anatomy.com/?scale=dimm&part=dramchip + https://pc-anatomy.com/?scale=dimm&part=dimmspd + https://pc-anatomy.com/?scale=dimm&part=dimmdecap + https://pc-anatomy.com/?scale=dimm&part=dimmcontacts + https://pc-anatomy.com/?scale=dimm&part=dimmpad + https://pc-anatomy.com/?scale=dimm&part=dimmspreader + https://pc-anatomy.com/?scale=dimm&part=dimmspreaderback + https://pc-anatomy.com/?scale=dimm&part=dimmpmic + https://pc-anatomy.com/?scale=dimm&part=dimminductor + https://pc-anatomy.com/?scale=dram&part=drammold + https://pc-anatomy.com/?scale=dram&part=dramdie + https://pc-anatomy.com/?scale=dram&part=drampads + https://pc-anatomy.com/?scale=dram&part=dramadhesive + https://pc-anatomy.com/?scale=dram&part=dramsubstrate + https://pc-anatomy.com/?scale=dram&part=drambondwire + https://pc-anatomy.com/?scale=dram&part=dramencap + https://pc-anatomy.com/?scale=dram&part=dramball + https://pc-anatomy.com/?scale=banks&part=drambank + https://pc-anatomy.com/?scale=banks&part=drambankgroup + https://pc-anatomy.com/?scale=banks&part=dramcmd + https://pc-anatomy.com/?scale=banks&part=drammoderegs + https://pc-anatomy.com/?scale=banks&part=dramrefresh + https://pc-anatomy.com/?scale=banks&part=dramecc + https://pc-anatomy.com/?scale=banks&part=dramprefetch + https://pc-anatomy.com/?scale=banks&part=dramdqio + https://pc-anatomy.com/?scale=banks&part=dramzq + https://pc-anatomy.com/?scale=bank&part=drammat + https://pc-anatomy.com/?scale=bank&part=dramlocalsa + https://pc-anatomy.com/?scale=bank&part=dramswd + https://pc-anatomy.com/?scale=bank&part=dramgwl + https://pc-anatomy.com/?scale=bank&part=dramgbl + https://pc-anatomy.com/?scale=bank&part=dramrowdec + https://pc-anatomy.com/?scale=bank&part=dramgsa + https://pc-anatomy.com/?scale=bank&part=dramcoldec + https://pc-anatomy.com/?scale=bank&part=drambankctl + https://pc-anatomy.com/?scale=cell&part=dramcapacitor + https://pc-anatomy.com/?scale=cell&part=dramaccess + https://pc-anatomy.com/?scale=cell&part=dramwordline + https://pc-anatomy.com/?scale=cell&part=drambitline + https://pc-anatomy.com/?scale=cell&part=dramplate + https://pc-anatomy.com/?scale=cell&part=dramwldriver + https://pc-anatomy.com/?scale=cell&part=dramprecharge + https://pc-anatomy.com/?scale=cell&part=dramcellsa + https://pc-anatomy.com/?scale=cell&part=dramcolsel + https://pc-anatomy.com/?scale=cell&part=dramlio + https://pc-anatomy.com/?scale=ddr4&part=ddr4board + https://pc-anatomy.com/?scale=ddr4&part=ddr4chip + https://pc-anatomy.com/?scale=ddr4&part=ddr4spd + https://pc-anatomy.com/?scale=ddr4&part=ddr4decap + https://pc-anatomy.com/?scale=ddr4&part=ddr4contacts + https://pc-anatomy.com/?scale=ddr4&part=ddr4pad + https://pc-anatomy.com/?scale=ddr4&part=ddr4spreader + https://pc-anatomy.com/?scale=ddr4&part=ddr4spreaderback + https://pc-anatomy.com/?scale=ddr4&part=ddr4term + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4mold + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4die + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4pads + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4adhesive + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4substrate + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4bondwire + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4encap + https://pc-anatomy.com/?scale=ddr4dram&part=ddr4ball + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4bank + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4bankgroup + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4cmd + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4moderegs + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4refresh + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4dll + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4prefetch + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4dqio + https://pc-anatomy.com/?scale=ddr4banks&part=ddr4zq + https://pc-anatomy.com/?scale=ddr4array&part=ddr4mat + https://pc-anatomy.com/?scale=ddr4array&part=ddr4localsa + https://pc-anatomy.com/?scale=ddr4array&part=ddr4swd + https://pc-anatomy.com/?scale=ddr4array&part=ddr4gwl + https://pc-anatomy.com/?scale=ddr4array&part=ddr4gbl + https://pc-anatomy.com/?scale=ddr4array&part=ddr4rowdec + https://pc-anatomy.com/?scale=ddr4array&part=ddr4gsa + https://pc-anatomy.com/?scale=ddr4array&part=ddr4coldec + https://pc-anatomy.com/?scale=ddr4array&part=ddr4bankctl + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4capacitor + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4access + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4wordline + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4bitline + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4plate + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4wldriver + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4precharge + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4cellsa + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4colsel + https://pc-anatomy.com/?scale=ddr4cell&part=ddr4lio + https://pc-anatomy.com/?scale=psu&part=psuintake + https://pc-anatomy.com/?scale=psu&part=psuboard + https://pc-anatomy.com/?scale=psu&part=psuinlet + https://pc-anatomy.com/?scale=psu&part=psufilter + https://pc-anatomy.com/?scale=psu&part=psubridge + https://pc-anatomy.com/?scale=psu&part=psupfc + https://pc-anatomy.com/?scale=psu&part=psubulk + https://pc-anatomy.com/?scale=psu&part=psuswitch + https://pc-anatomy.com/?scale=psu&part=psutransformer + https://pc-anatomy.com/?scale=psu&part=psusinks + https://pc-anatomy.com/?scale=psu&part=psusecondary + https://pc-anatomy.com/?scale=psu&part=psudcdc + https://pc-anatomy.com/?scale=psu&part=psuoutput + https://pc-anatomy.com/?scale=psu&part=psusupervisor + https://pc-anatomy.com/?scale=psu&part=psumodular + https://pc-anatomy.com/?scale=psu&part=psugrille + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuintake + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuboard + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuinlet + https://pc-anatomy.com/?scale=psubronze&part=bronzepsufilter + https://pc-anatomy.com/?scale=psubronze&part=bronzepsubridge + https://pc-anatomy.com/?scale=psubronze&part=bronzepsupfc + https://pc-anatomy.com/?scale=psubronze&part=bronzepsubulk + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuswitch + https://pc-anatomy.com/?scale=psubronze&part=bronzepsutransformer + https://pc-anatomy.com/?scale=psubronze&part=bronzepsusinks + https://pc-anatomy.com/?scale=psubronze&part=bronzepsusecondary + https://pc-anatomy.com/?scale=psubronze&part=bronzepsudcdc + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuoutput + https://pc-anatomy.com/?scale=psubronze&part=bronzepsusupervisor + https://pc-anatomy.com/?scale=psubronze&part=bronzepsuharness + https://pc-anatomy.com/?scale=psubronze&part=bronzepsugrille + https://pc-anatomy.com/?scale=fan&part=fanpads + https://pc-anatomy.com/?scale=fan&part=fanimpeller + https://pc-anatomy.com/?scale=fan&part=fanmagnet + https://pc-anatomy.com/?scale=fan&part=fanstator + https://pc-anatomy.com/?scale=fan&part=fanbearing + https://pc-anatomy.com/?scale=fan&part=fanboard + https://pc-anatomy.com/?scale=fan&part=fanlead + https://pc-anatomy.com/?scale=cooler&part=coolerbase + https://pc-anatomy.com/?scale=cooler&part=coolerpaste + https://pc-anatomy.com/?scale=cooler&part=coolerpipes + https://pc-anatomy.com/?scale=cooler&part=coolerfins + https://pc-anatomy.com/?scale=cooler&part=coolercap + https://pc-anatomy.com/?scale=cooler&part=coolerfan + https://pc-anatomy.com/?scale=cooler&part=coolerclip + https://pc-anatomy.com/?scale=cooler&part=coolermount + https://pc-anatomy.com/?scale=liquid&part=aioradiator + https://pc-anatomy.com/?scale=liquid&part=aiofans + https://pc-anatomy.com/?scale=liquid&part=aiopump + https://pc-anatomy.com/?scale=liquid&part=aioimpeller + https://pc-anatomy.com/?scale=liquid&part=aiocoldplate + https://pc-anatomy.com/?scale=liquid&part=aiotubes + https://pc-anatomy.com/?scale=liquid&part=aiocoolant + https://pc-anatomy.com/?scale=liquid&part=aiofittings + https://pc-anatomy.com/?scale=ryzen&part=zenccd + https://pc-anatomy.com/?scale=ryzen&part=zencore + https://pc-anatomy.com/?scale=ryzen&part=zenl3 + https://pc-anatomy.com/?scale=ryzen&part=zeniod + https://pc-anatomy.com/?scale=ryzenio&part=zeniomemory + https://pc-anatomy.com/?scale=ryzenio&part=zeniopcie + https://pc-anatomy.com/?scale=ryzenio&part=zeniofabric + https://pc-anatomy.com/?scale=ryzenio&part=zeniodisplay + https://pc-anatomy.com/?scale=ryzenio&part=zeniousb + https://pc-anatomy.com/?scale=ryzen&part=zenfabric + https://pc-anatomy.com/?scale=corei9&part=arrowcompute + https://pc-anatomy.com/?scale=corei9&part=arrowpcore + https://pc-anatomy.com/?scale=corei9&part=arrowecore + https://pc-anatomy.com/?scale=corei9&part=arrowl3 + https://pc-anatomy.com/?scale=corei9&part=arrowsoc + https://pc-anatomy.com/?scale=corei9&part=arrowgpu + https://pc-anatomy.com/?scale=corei9&part=arrowio + https://pc-anatomy.com/?scale=coreio&part=arrowiopcie + https://pc-anatomy.com/?scale=coreio&part=arrowiothunderbolt + https://pc-anatomy.com/?scale=coreio&part=arrowiod2d + https://pc-anatomy.com/?scale=coreio&part=arrowioclock + https://pc-anatomy.com/?scale=corei9&part=arrowbase + https://pc-anatomy.com/?scale=ssd&part=ssdcase + https://pc-anatomy.com/?scale=ssd&part=ssdcover + https://pc-anatomy.com/?scale=ssd&part=ssdboard + https://pc-anatomy.com/?scale=ssd&part=ssdcontroller + https://pc-anatomy.com/?scale=ssd&part=ssdnand + https://pc-anatomy.com/?scale=ssd&part=ssddram + https://pc-anatomy.com/?scale=ssd&part=ssddata + https://pc-anatomy.com/?scale=ssd&part=ssdpower + https://pc-anatomy.com/?scale=nvme&part=nvmeboard + https://pc-anatomy.com/?scale=nvme&part=nvmecontroller + https://pc-anatomy.com/?scale=nvme&part=nvmenand + https://pc-anatomy.com/?scale=nvme&part=nvmedram + https://pc-anatomy.com/?scale=nvme&part=nvmecontacts + https://pc-anatomy.com/?scale=nvme&part=nvmefastener + https://pc-anatomy.com/?scale=card&part=shroud + https://pc-anatomy.com/?scale=card&part=fan + https://pc-anatomy.com/?scale=card&part=heatsink + https://pc-anatomy.com/?scale=card&part=vapor + https://pc-anatomy.com/?scale=card&part=backplate + https://pc-anatomy.com/?scale=card&part=pcb + https://pc-anatomy.com/?scale=card&part=package + https://pc-anatomy.com/?scale=card&part=gddr7 + https://pc-anatomy.com/?scale=card&part=vrm + https://pc-anatomy.com/?scale=card&part=capacitor + https://pc-anatomy.com/?scale=card&part=pcie + https://pc-anatomy.com/?scale=card&part=power + https://pc-anatomy.com/?scale=die&part=gpc + https://pc-anatomy.com/?scale=die&part=l2 + https://pc-anatomy.com/?scale=die&part=controller + https://pc-anatomy.com/?scale=gpc&part=tpc + https://pc-anatomy.com/?scale=gpc&part=raster + https://pc-anatomy.com/?scale=gpc&part=rop + https://pc-anatomy.com/?scale=tpc&part=polymorph + https://pc-anatomy.com/?scale=tpc&part=sm + https://pc-anatomy.com/?scale=sm&part=cuda + https://pc-anatomy.com/?scale=sm&part=tensor + https://pc-anatomy.com/?scale=sm&part=rt + https://pc-anatomy.com/?scale=sm&part=texture + https://pc-anatomy.com/?scale=sm&part=scheduler + https://pc-anatomy.com/?scale=sm&part=register + https://pc-anatomy.com/?scale=sm&part=l1 + https://pc-anatomy.com/?scale=sm&part=loadstore + https://pc-anatomy.com/?scale=sm&part=sfu + https://pc-anatomy.com/?scale=card&part=thermalpad + https://pc-anatomy.com/?scale=card&part=heatpipe + https://pc-anatomy.com/?scale=card&part=powerstage + https://pc-anatomy.com/?scale=card&part=mlcc + https://pc-anatomy.com/?scale=card&part=resistor + https://pc-anatomy.com/?scale=card&part=fastener + https://pc-anatomy.com/?scale=card&part=bracket + https://pc-anatomy.com/?scale=card&part=silicon + https://pc-anatomy.com/?scale=card&part=bga + https://pc-anatomy.com/?scale=card&part=tim + https://pc-anatomy.com/?scale=card&part=retention + https://pc-anatomy.com/?scale=card&part=standoff + https://pc-anatomy.com/?scale=card&part=pwm + https://pc-anatomy.com/?scale=card&part=bios + https://pc-anatomy.com/?scale=card&part=shunt + https://pc-anatomy.com/?scale=card&part=monitor + https://pc-anatomy.com/?scale=card&part=temperature + https://pc-anatomy.com/?scale=card&part=crystal + https://pc-anatomy.com/?scale=card&part=fanheader + https://pc-anatomy.com/?scale=card&part=fancable + https://pc-anatomy.com/?scale=card&part=fanmotor + https://pc-anatomy.com/?scale=card&part=fuse + https://pc-anatomy.com/?scale=card&part=esd + https://pc-anatomy.com/?scale=card&part=auxreg + https://pc-anatomy.com/?scale=card&part=testpoint + https://pc-anatomy.com/?scale=card&part=displayport + https://pc-anatomy.com/?scale=card&part=hdmi + https://pc-anatomy.com/?scale=die&part=nvenc + https://pc-anatomy.com/?scale=die&part=nvdec + https://pc-anatomy.com/?scale=die&part=displayengine + https://pc-anatomy.com/?scale=die&part=host + https://pc-anatomy.com/?scale=die&part=command + https://pc-anatomy.com/?scale=rx9070&part=rxshroud + https://pc-anatomy.com/?scale=rx9070&part=rxfan + https://pc-anatomy.com/?scale=rx9070&part=rxfanmotor + https://pc-anatomy.com/?scale=rx9070&part=rxheatsink + https://pc-anatomy.com/?scale=rx9070&part=rxheatpipe + https://pc-anatomy.com/?scale=rx9070&part=rxbaseplate + https://pc-anatomy.com/?scale=rx9070&part=rxvrmplate + https://pc-anatomy.com/?scale=rx9070&part=rxframe + https://pc-anatomy.com/?scale=rx9070&part=rxbackplate + https://pc-anatomy.com/?scale=rx9070&part=rxmagnet + https://pc-anatomy.com/?scale=rx9070&part=rxlight + https://pc-anatomy.com/?scale=rx9070&part=rxbracket + https://pc-anatomy.com/?scale=rx9070&part=rxfastener + https://pc-anatomy.com/?scale=rx9070&part=rxthermalpad + https://pc-anatomy.com/?scale=rx9070&part=rxtim + https://pc-anatomy.com/?scale=rx9070&part=rxretention + https://pc-anatomy.com/?scale=rx9070&part=rxstandoff + https://pc-anatomy.com/?scale=rx9070&part=rxpcb + https://pc-anatomy.com/?scale=rx9070&part=rxpackage + https://pc-anatomy.com/?scale=rx9070&part=rxsilicon + https://pc-anatomy.com/?scale=rx9070&part=rxbga + https://pc-anatomy.com/?scale=rx9070&part=rxgddr6 + https://pc-anatomy.com/?scale=rx9070&part=rxvrm + https://pc-anatomy.com/?scale=rx9070&part=rxpowerstage + https://pc-anatomy.com/?scale=rx9070&part=rxpwm + https://pc-anatomy.com/?scale=rx9070&part=rxcapacitor + https://pc-anatomy.com/?scale=rx9070&part=rxmlcc + https://pc-anatomy.com/?scale=rx9070&part=rxresistor + https://pc-anatomy.com/?scale=rx9070&part=rxpcie + https://pc-anatomy.com/?scale=rx9070&part=rxpower + https://pc-anatomy.com/?scale=rx9070&part=rxargb + https://pc-anatomy.com/?scale=rx9070&part=rxbios + https://pc-anatomy.com/?scale=rx9070&part=rxdisplayport + https://pc-anatomy.com/?scale=rx9070&part=rxhdmi + https://pc-anatomy.com/?scale=rx9070&part=rxfanheader + https://pc-anatomy.com/?scale=rx9070&part=rxfancable + https://pc-anatomy.com/?scale=rx9070&part=rxtemperature + https://pc-anatomy.com/?scale=rx9070&part=rxesd + https://pc-anatomy.com/?scale=rx9070&part=rxcrystal + https://pc-anatomy.com/?scale=navi48&part=rxse + https://pc-anatomy.com/?scale=navi48&part=rxl2 + https://pc-anatomy.com/?scale=navi48&part=rxinfinity + https://pc-anatomy.com/?scale=navi48&part=rxmemctl + https://pc-anatomy.com/?scale=navi48&part=rxcommand + https://pc-anatomy.com/?scale=navi48&part=rxmedia + https://pc-anatomy.com/?scale=navi48&part=rxdisplay + https://pc-anatomy.com/?scale=navi48&part=rxhost + https://pc-anatomy.com/?scale=navi48&part=rxcompress + https://pc-anatomy.com/?scale=navi48&part=rxmp0 + https://pc-anatomy.com/?scale=rxse&part=rxwgp + https://pc-anatomy.com/?scale=rxse&part=rxraster + https://pc-anatomy.com/?scale=rxse&part=rxprim + https://pc-anatomy.com/?scale=rxse&part=rxrb + https://pc-anatomy.com/?scale=rxse&part=rxra + https://pc-anatomy.com/?scale=rxwgp&part=rxcu + https://pc-anatomy.com/?scale=rxwgp&part=rxslm + https://pc-anatomy.com/?scale=rxwgp&part=rxsched + https://pc-anatomy.com/?scale=rxcu&part=rxalu + https://pc-anatomy.com/?scale=rxcu&part=rxaluint + https://pc-anatomy.com/?scale=rxcu&part=rxmatrix + https://pc-anatomy.com/?scale=rxcu&part=rxtlu + https://pc-anatomy.com/?scale=rxcu&part=rxray + https://pc-anatomy.com/?scale=rxcu&part=rxl0 + https://pc-anatomy.com/?scale=rxcu&part=rxvgpr + https://pc-anatomy.com/?scale=rxcu&part=rxsgpr + https://pc-anatomy.com/?scale=rxcu&part=rxscalar + https://pc-anatomy.com/?scale=rxcu&part=rxscache + https://pc-anatomy.com/?scale=rxcu&part=rxsic + https://pc-anatomy.com/?scale=rxcu&part=rxtexls + https://pc-anatomy.com/?scale=arcb580&part=arcshroud + https://pc-anatomy.com/?scale=arcb580&part=arcfan + https://pc-anatomy.com/?scale=arcb580&part=arcfanmotor + https://pc-anatomy.com/?scale=arcb580&part=archeatsink + https://pc-anatomy.com/?scale=arcb580&part=archeatpipe + https://pc-anatomy.com/?scale=arcb580&part=arccoldplate + https://pc-anatomy.com/?scale=arcb580&part=arcbackplate + https://pc-anatomy.com/?scale=arcb580&part=arclogo + https://pc-anatomy.com/?scale=arcb580&part=arcbracket + https://pc-anatomy.com/?scale=arcb580&part=arcfastener + https://pc-anatomy.com/?scale=arcb580&part=arcthermalpad + https://pc-anatomy.com/?scale=arcb580&part=arctim + https://pc-anatomy.com/?scale=arcb580&part=arcretention + https://pc-anatomy.com/?scale=arcb580&part=arcstandoff + https://pc-anatomy.com/?scale=arcb580&part=arcpcb + https://pc-anatomy.com/?scale=arcb580&part=arcpackage + https://pc-anatomy.com/?scale=arcb580&part=arcsilicon + https://pc-anatomy.com/?scale=arcb580&part=arcbga + https://pc-anatomy.com/?scale=arcb580&part=arcgddr6 + https://pc-anatomy.com/?scale=arcb580&part=arcvrm + https://pc-anatomy.com/?scale=arcb580&part=arcpowerstage + https://pc-anatomy.com/?scale=arcb580&part=arcpwm + https://pc-anatomy.com/?scale=arcb580&part=arccapacitor + https://pc-anatomy.com/?scale=arcb580&part=arcmlcc + https://pc-anatomy.com/?scale=arcb580&part=arcresistor + https://pc-anatomy.com/?scale=arcb580&part=arcpcie + https://pc-anatomy.com/?scale=arcb580&part=arcpower + https://pc-anatomy.com/?scale=arcb580&part=arcbios + https://pc-anatomy.com/?scale=arcb580&part=arcdisplayport + https://pc-anatomy.com/?scale=arcb580&part=archdmi + https://pc-anatomy.com/?scale=arcb580&part=arcfanheader + https://pc-anatomy.com/?scale=arcb580&part=arcfancable + https://pc-anatomy.com/?scale=arcb580&part=arctemperature + https://pc-anatomy.com/?scale=arcb580&part=arcesd + https://pc-anatomy.com/?scale=arcb580&part=arccrystal + https://pc-anatomy.com/?scale=bmg&part=xeslice + https://pc-anatomy.com/?scale=bmg&part=arcl2 + https://pc-anatomy.com/?scale=bmg&part=arcmfx + https://pc-anatomy.com/?scale=bmg&part=arcdisplay + https://pc-anatomy.com/?scale=bmg&part=archost + https://pc-anatomy.com/?scale=bmg&part=arcmemif + https://pc-anatomy.com/?scale=xeslice&part=xecore + https://pc-anatomy.com/?scale=xeslice&part=arcpixel + https://pc-anatomy.com/?scale=xeslice&part=arcsampler + https://pc-anatomy.com/?scale=xecore&part=xve + https://pc-anatomy.com/?scale=xecore&part=arcrtu + https://pc-anatomy.com/?scale=xecore&part=arcl1 + https://pc-anatomy.com/?scale=xecore&part=arcslm + https://pc-anatomy.com/?scale=xve&part=arcalu + https://pc-anatomy.com/?scale=xve&part=arcxmx + https://pc-anatomy.com/?scale=xve&part=arcthread + https://pc-anatomy.com/?scale=xve&part=arcregister + https://pc-anatomy.com/?scale=xve&part=arcem + https://pc-anatomy.com/?scale=xve&part=arccoissue diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 0000000..b1e1c83 --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,50 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; + +/** + * Keeps the 3D engine out of the first download. + * + * The interface is meant to paint before three.js arrives (see "Loading" in + * ARCHITECTURE.md). That holds only while nothing the entry imports reaches + * a module that imports three, and one stray `import` from a builder file is + * enough to drag the whole engine back in without any error. This runs after + * `vite build` and fails if the entry chunk carries three.js or grows past + * its budget. Raise the budget deliberately, in the same commit as the reason. + */ + +const BUDGET_KB = 220; // gzipped entry chunk; about 185 KB when this was set +const ENGINE_MARKERS = ['WebGLRenderer', 'BufferGeometry', 'mergeGeometries']; + +const dist = path.resolve(import.meta.dirname, '../dist'); +const html = fs.readFileSync(path.join(dist, 'index.html'), 'utf8'); +const entry = html.match( + /]+type="module"[^>]+src="\/?([^"]+\.js)"/, +)?.[1]; +if (!entry) { + console.error( + 'Bundle check: no module script found in dist/index.html. Run the build first.', + ); + process.exit(1); +} + +const code = fs.readFileSync(path.join(dist, entry)); +const gzipKb = gzipSync(code).length / 1024; +const leaked = ENGINE_MARKERS.filter((marker) => code.includes(marker)); +const failures = []; + +if (leaked.length) + failures.push( + `three.js is in the entry chunk (found ${leaked.join(', ')}). Something the interface imports eagerly imports three; move what it needs into a module that does not.`, + ); +if (gzipKb > BUDGET_KB) + failures.push( + `entry chunk is ${gzipKb.toFixed(1)} KB gzipped, over the ${BUDGET_KB} KB budget.`, + ); + +console.log( + `Bundle check: ${entry} is ${gzipKb.toFixed(1)} KB gzipped (budget ${BUDGET_KB} KB).`, +); +for (const failure of failures) + console.error(`Bundle check failed: ${failure}`); +process.exit(failures.length ? 1 : 0); diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs new file mode 100644 index 0000000..cde90c1 --- /dev/null +++ b/scripts/generate-sitemap.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import { sitemapUrls } from './sitemap-urls.ts'; + +// Run with Node's --experimental-strip-types after adding scales or parts. +// tests/discovery.test.ts fails when the committed sitemap falls behind. +const escape = (url) => url.replaceAll('&', '&'); +fs.writeFileSync( + new URL('../public/sitemap.xml', import.meta.url), + [ + '', + '', + ...sitemapUrls().map((url) => ` ${escape(url)}`), + '', + '', + ].join('\n'), +); diff --git a/scripts/sitemap-urls.ts b/scripts/sitemap-urls.ts new file mode 100644 index 0000000..2d7e90f --- /dev/null +++ b/scripts/sitemap-urls.ts @@ -0,0 +1,31 @@ +import { isLevelRoot, manifest } from '../lib/manifest.ts'; +import { levelIds } from '../lib/levels.ts'; +import { placeToSearch } from '../lib/deep-link.ts'; + +export const SITE = 'https://pc-anatomy.com/'; + +/** + * Every address worth a search result: the site, the guide, each scale, and + * each part on the scale where it is described. Built from the same catalogue + * and the same link format the explorer uses, so a listed address always + * opens on what it names. + */ +export function sitemapUrls(): string[] { + const places = [ + ...levelIds.map((level) => ({ level, selection: null, explode: 0 })), + ...manifest + .filter((c) => !isLevelRoot(c)) + .map((c) => ({ + level: c.level, + selection: { concept: c.id }, + explode: 0, + })), + ]; + return [ + ...new Set([ + SITE, + SITE + 'guide/', + ...places.map((place) => SITE + placeToSearch(place)), + ]), + ]; +} diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts new file mode 100644 index 0000000..3d304ce --- /dev/null +++ b/tests/discovery.test.ts @@ -0,0 +1,174 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { manifest, isLevelRoot } from '../lib/manifest.ts'; +import { isPhysical, levelIds, levels, rootLevel } from '../lib/levels.ts'; +import { + homePlace, + placeTitle, + placeToSearch, + searchToPlace, + type Place, +} from '../lib/deep-link.ts'; +import { tapIntent } from '../lib/pointer-intent.ts'; +import { labelWidth, placeLabels } from '../lib/inventory-labels.ts'; +import { sitemapUrls } from '../scripts/sitemap-urls.ts'; + +// Everything here is about being found and understood: links that land on a +// place, the first tap on a phone, names on the laid-out parts, and the +// interface loading before the engine does. + +await test('the home place is the bare address, and the bare address is home', () => { + assert.equal(placeToSearch(homePlace), ''); + assert.deepEqual(searchToPlace(''), homePlace); + assert.deepEqual(searchToPlace('?utm_source=x'), homePlace); +}); + +await test('every scale and every part on it survives a round trip through a link', () => { + for (const level of levelIds) { + const scale: Place = { level, selection: null, explode: 0 }; + assert.deepEqual(searchToPlace(placeToSearch(scale)), scale, level); + } + for (const c of manifest) { + if (isLevelRoot(c)) continue; + const place: Place = { + level: c.level, + selection: { concept: c.id }, + explode: 0, + }; + assert.deepEqual(searchToPlace(placeToSearch(place)), place, c.id); + } +}); + +await test('a link keeps the instance and how far apart the hardware is', () => { + const part = manifest.find((c) => c.level === 'pc' && !isLevelRoot(c))!; + const place: Place = { + level: 'pc', + selection: { concept: part.id, instance: 2 }, + explode: 48, + }; + const search = placeToSearch(place); + assert.match(search, /[?&]n=3(&|$)/); + assert.match(search, /[?&]open=48(&|$)/); + assert.deepEqual(searchToPlace(search), place); +}); + +await test('arriving on a scale may describe the part that was opened to get there', () => { + const level = levelIds.find((id) => id !== rootLevel)!; + const place: Place = { + level, + selection: { concept: levels[level].concept }, + explode: 0, + }; + assert.deepEqual(searchToPlace(placeToSearch(place)), place); +}); + +await test('links that do not make sense open the nearest thing that does', () => { + assert.deepEqual(searchToPlace('?scale=nope&part=fan'), homePlace); + assert.deepEqual(searchToPlace('?scale=toString'), homePlace); + const elsewhere = manifest.find( + (c) => c.level !== 'pc' && !isLevelRoot(c) && c.id !== levels.pc.concept, + )!; + assert.equal(searchToPlace(`?scale=pc&part=${elsewhere.id}`).selection, null); + assert.equal(searchToPlace('?scale=pc&open=250').explode, 100); + assert.equal(searchToPlace('?scale=pc&open=-4').explode, 0); + assert.equal(searchToPlace('?scale=pc&open=lots').explode, 0); + const logical = levelIds.find((id) => !isPhysical(id))!; + assert.equal(searchToPlace(`?scale=${logical}&open=60`).explode, 0); + const part = manifest.find((c) => c.level === 'pc' && !isLevelRoot(c))!; + assert.deepEqual(searchToPlace(`?scale=pc&part=${part.id}&n=0`).selection, { + concept: part.id, + }); +}); + +await test('a place is titled after what it shows', () => { + assert.equal(placeTitle(homePlace), 'PC Anatomy'); + const part = manifest.find((c) => c.level === 'pc' && !isLevelRoot(c))!; + assert.equal( + placeTitle({ level: 'pc', selection: { concept: part.id }, explode: 0 }), + `${part.name} · PC Anatomy`, + ); + const level = levelIds.find((id) => id !== rootLevel)!; + assert.equal( + placeTitle({ level, selection: null, explode: 0 }), + `${levels[level].name} · PC Anatomy`, + ); +}); + +await test('the sitemap lists every place a link can name, and is up to date', () => { + const urls = sitemapUrls(); + assert.equal(new Set(urls).size, urls.length); + for (const url of urls) { + const search = new URL(url).search; + assert.equal(placeToSearch(searchToPlace(search)), search, url); + } + const written = fs.readFileSync( + new URL('../public/sitemap.xml', import.meta.url), + 'utf8', + ); + for (const url of urls) + assert.ok( + written.includes(`${url.replaceAll('&', '&')}`), + `public/sitemap.xml is missing ${url}; run scripts/generate-sitemap.mjs`, + ); +}); + +await test('a mouse click opens, and a tap describes first and opens on the second tap', () => { + const part = { concept: 'fan', instance: 1 }; + assert.equal(tapIntent('mouse', part, null), 'open'); + assert.equal(tapIntent('touch', part, null), 'inspect'); + assert.equal( + tapIntent('touch', part, { concept: 'fan', instance: 0 }), + 'inspect', + ); + assert.equal( + tapIntent('touch', part, { concept: 'fan', instance: 1 }), + 'open', + ); + assert.equal(tapIntent('pen', part, { concept: 'fan', instance: 1 }), 'open'); + assert.equal( + tapIntent('touch', { concept: 'fan', instance: 0 }, { concept: 'fan' }), + 'open', + ); + assert.equal(tapIntent('touch', null, { concept: 'fan' }), 'inspect'); +}); + +await test('part names go on the biggest parts first and never overlap or leave the stage', () => { + const candidates = [ + { concept: 'a', instance: 0, name: 'Small', x: 100, y: 100, size: 5 }, + { concept: 'b', instance: 0, name: 'Big part', x: 110, y: 104, size: 50 }, + { concept: 'c', instance: 0, name: 'Chip', x: 300, y: 200, size: 10 }, + { concept: 'c', instance: 1, name: 'Chip', x: 300, y: 260, size: 10 }, + { concept: 'd', instance: 0, name: 'Edge', x: 2, y: 200, size: 40 }, + ]; + const placed = placeLabels(candidates, 400, 300); + assert.deepEqual( + placed.map((l) => `${l.concept}${l.instance}`), + ['b0', 'c0'], + ); + for (const l of placed) { + assert.ok(l.left >= 0 && l.left + labelWidth(l.name) <= 400); + assert.ok(l.top >= 0 && l.top + 22 <= 300); + } + assert.equal(placeLabels(candidates, 400, 300, 1).length, 1); +}); + +await test('the interface can ask about airflow without loading three.js', () => { + const source = fs.readFileSync( + new URL('../lib/airflow-policy.ts', import.meta.url), + 'utf8', + ); + assert.doesNotMatch(source, /from 'three/); + for (const file of [ + '../app/use-explorer.ts', + '../app/stage-tools.tsx', + '../lib/explorer-state.ts', + '../lib/deep-link.ts', + '../lib/pointer-intent.ts', + '../lib/inventory-labels.ts', + ]) { + const code = fs.readFileSync(new URL(file, import.meta.url), 'utf8'); + assert.doesNotMatch(code, /from '(@\/lib|\.)\/airflow(\.ts)?'/, file); + assert.doesNotMatch(code, /from 'three/, file); + } +});