Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/components/AnatomyApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BookOpen,
Bookmark,
BrainCircuit,
Check,
ChevronDown,
CircleHelp,
Compass,
Expand Down Expand Up @@ -139,11 +140,16 @@ export function AnatomyApp() {
<button aria-label="Close library" className="mobile-close" onClick={() => setMobileLibrary(false)}><X size={17} /></button>
<button aria-label="Saved organs"><Bookmark size={17} /></button>
</div>
<div className="organ-list">
{/* Picking an organ is a single choice among many, so the list is a
radio group — the check mark has a programmatic equivalent rather
than being colour and an icon alone. */}
<div className="organ-list" role="radiogroup" aria-label="Organ library">
{filteredOrgans.map((item) => (
<button
type="button"
key={item.id}
role="radio"
aria-checked={organId === item.id}
className={`organ-item ${organId === item.id ? "active" : ""}`}
onClick={() => selectOrgan(item.id)}
onPointerEnter={() => prefetchOrgan(item.id)}
Expand All @@ -154,7 +160,7 @@ export function AnatomyApp() {
<OrganArt organ={item} asset="thumb" alt={`${item.name} thumbnail`} size={47} />
</span>
<span><b>{item.name}</b><small>{item.system}</small></span>
{organId === item.id && <Heart className="favorite" size={14} fill="currentColor" />}
{organId === item.id && <Check className="selected-mark" size={15} strokeWidth={2.6} aria-hidden />}
</button>
))}
</div>
Expand Down
92 changes: 74 additions & 18 deletions app/components/OrganViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
Box,
CircleDashed,
Grid2x2,
Layers3,
Maximize2,
RotateCcw,
ScanLine,
Search,
Sparkles,
X,
} from "lucide-react";
import type { Hotspot, Organ } from "../lib/anatomy-data";
import type { AnatomyViewer } from "../lib/three/viewer";
import type { AnatomyViewer, SectionAxis } from "../lib/three/viewer";
import { SectionControls } from "./SectionControls";

const SECTION_DEFAULTS = { axis: "free" as SectionAxis, depth: 0, flipped: false, showPlane: true };

type Props = {
organ: Organ;
Expand All @@ -32,7 +35,10 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
const [slowLoad, setSlowLoad] = useState(false);
const [activeTool, setActiveTool] = useState<string | null>(null);
// The three viewer tools are independent toggles, so a single "active tool"
// could only ever describe one of them while the other two stayed on unseen.
const [active, setActive] = useState({ isolate: false, section: false, wireframe: false });
const [section, setSection] = useState(SECTION_DEFAULTS);

// A typical organ is ready well inside a second — flashing a loading panel for
// that reads as jank. It only appears if the fetch is genuinely slow; the flag
Expand Down Expand Up @@ -101,39 +107,76 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
if (!viewer) return;
if (tool === "rotate") onAutoRotate(!autoRotate);
if (tool === "zoom") viewer.zoom(-1);
if (tool === "isolate") setActiveTool(viewer.toggleIsolate() ? tool : null);
if (tool === "section") setActiveTool(viewer.toggleCrossSection() ? tool : null);
if (tool === "layers") setActiveTool(viewer.toggleLayers() ? tool : null);
if (tool === "isolate") setActive((state) => ({ ...state, isolate: viewer.toggleIsolate() }));
if (tool === "section") {
const on = viewer.toggleCrossSection();
setActive((state) => ({ ...state, section: on }));
// A specimen that keeps turning is no use while you are reading its cut
// face — but this goes through the normal control, so it can be turned
// straight back on rather than being locked out.
if (on && autoRotate) onAutoRotate(false);
}
if (tool === "wireframe") setActive((state) => ({ ...state, wireframe: viewer.toggleWireframe() }));
if (tool === "compare") onCompare();
if (tool === "reset") {
// reset() clears every tool in the viewer too, so mirroring it here keeps
// the buttons honest.
viewer.reset();
setActiveTool(null);
setActive({ isolate: false, section: false, wireframe: false });
setSection(SECTION_DEFAULTS);
}
};

const handleAxis = (axis: SectionAxis) => {
viewerRef.current?.setSectionAxis(axis);
setSection((state) => ({ ...state, axis }));
};

const handleDepth = (depth: number) => {
viewerRef.current?.setSectionDepth(depth);
setSection((state) => ({ ...state, depth }));
};

const handleFlip = () => {
const flipped = viewerRef.current?.toggleSectionFlip() ?? false;
setSection((state) => ({ ...state, flipped }));
};

const handleShowPlane = (shown: boolean) => {
const showPlane = viewerRef.current?.setSectionPlaneVisible(shown) ?? true;
setSection((state) => ({ ...state, showPlane }));
};

const tools = [
{ id: "rotate", label: "Rotate", icon: RotateCcw },
{ id: "zoom", label: "Zoom", icon: Search },
{ id: "isolate", label: "Isolate", icon: CircleDashed },
{ id: "section", label: "Cross-section", icon: ScanLine },
{ id: "layers", label: "Layers", icon: Layers3 },
{ id: "compare", label: "Compare", icon: Box },
{ id: "reset", label: "Reset", icon: RotateCcw },
];
{ id: "rotate", label: "Rotate", icon: RotateCcw, toggle: true },
{ id: "zoom", label: "Zoom", icon: Search, toggle: false },
{ id: "isolate", label: "Isolate", icon: CircleDashed, toggle: true },
// The layered metaphor belongs to the tool that actually opens the organ up.
{ id: "section", label: "Cross-section", icon: Layers3, toggle: true },
{ id: "wireframe", label: "Wireframe", icon: Grid2x2, toggle: true },
{ id: "compare", label: "Compare", icon: Box, toggle: true },
{ id: "reset", label: "Reset", icon: RotateCcw, toggle: false },
] as const;

const isActive = (id: string) => {
if (id === "compare") return compare;
if (id === "rotate") return autoRotate;
return id in active && active[id as keyof typeof active];
};

return (
<section className="viewer-shell" aria-label={`${organ.name} interactive viewer`}>
<div className="viewer-glow" style={{ "--organ-accent": organ.accent } as React.CSSProperties} />
<div ref={mountRef} className="three-mount" />

<div className="viewer-tools" aria-label="3D viewer tools">
{tools.map(({ id, label, icon: Icon }) => (
{tools.map(({ id, label, icon: Icon, toggle }) => (
<button
key={id}
type="button"
className={`tool-button ${(activeTool === id || (id === "compare" && compare)) ? "active" : ""}`}
className={`tool-button ${isActive(id) ? "active" : ""}`}
onClick={() => handleTool(id)}
aria-pressed={activeTool === id || (id === "compare" && compare)}
aria-pressed={toggle ? isActive(id) : undefined}
title={label}
>
<Icon size={19} strokeWidth={1.65} />
Expand All @@ -142,6 +185,19 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
))}
</div>

{active.section && (
<SectionControls
axis={section.axis}
depth={section.depth}
flipped={section.flipped}
showPlane={section.showPlane}
onAxis={handleAxis}
onDepth={handleDepth}
onFlip={handleFlip}
onShowPlane={handleShowPlane}
/>
)}

<aside className="tip-note" aria-label="Viewer instructions">
<span><Sparkles size={15} /> Tip</span>
<p>Drag to rotate<br />Scroll to zoom<br />Click a dot to learn more</p>
Expand Down
83 changes: 83 additions & 0 deletions app/components/SectionControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import { FlipHorizontal2 } from "lucide-react";
import type { SectionAxis } from "../lib/three/viewer";

/** The three named planes anatomy is taught in, plus one that follows the view.
* The hints are the point: they are the vocabulary a learner needs. */
const AXES: { id: SectionAxis; label: string; hint: string }[] = [
{ id: "sagittal", label: "Sagittal", hint: "Divides left from right" },
{ id: "coronal", label: "Coronal", hint: "Divides front from back" },
{ id: "axial", label: "Axial", hint: "Divides top from bottom" },
{ id: "free", label: "Free", hint: "Squares up to the current view" },
];

type Props = {
axis: SectionAxis;
depth: number;
flipped: boolean;
showPlane: boolean;
onAxis: (axis: SectionAxis) => void;
onDepth: (depth: number) => void;
onFlip: () => void;
onShowPlane: (shown: boolean) => void;
};

export function SectionControls({ axis, depth, flipped, showPlane, onAxis, onDepth, onFlip, onShowPlane }: Props) {
const active = AXES.find((item) => item.id === axis) ?? AXES[3];
return (
<div className="section-controls" role="group" aria-label="Cross-section plane">
<div className="section-axes" role="radiogroup" aria-label="Cutting plane">
{AXES.map(({ id, label, hint }) => (
<button
key={id}
type="button"
role="radio"
aria-checked={axis === id}
className={axis === id ? "active" : ""}
onClick={() => onAxis(id)}
title={hint}
>
{label}
</button>
))}
</div>

<label className="section-depth">
<span>Depth</span>
<input
type="range"
min={-1}
max={1}
step={0.01}
value={depth}
onChange={(event) => onDepth(Number(event.target.value))}
aria-label="Cutting plane depth"
// Percentages read better than the -1..1 the viewer works in.
aria-valuetext={`${Math.round(((depth + 1) / 2) * 100)}% through the specimen`}
/>
</label>

<button
type="button"
className={`section-flip ${flipped ? "active" : ""}`}
onClick={onFlip}
aria-pressed={flipped}
title="Show the other half"
>
<FlipHorizontal2 size={15} />
</button>

<label className="section-plane" title="Draw the cutting plane in the scene">
<input
type="checkbox"
checked={showPlane}
onChange={(event) => onShowPlane(event.target.checked)}
/>
<span>Plane</span>
</label>

<em aria-live="polite">{active.hint}</em>
</div>
);
}
66 changes: 65 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.organ-glyph img { width: 100%; height: 100%; object-fit: cover; mix-blend-mode: multiply; transform: scale(1.14); }
.organ-item b { display: block; font: 500 17px/1.1 var(--font-serif), serif; }
.organ-item small { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
.organ-item .favorite { color: #ef8d7d; }
/* Marks the selected organ. A heart lived here, which read as "favourited" —
the Bookmark control in the panel heading owns that meaning. */
.organ-item .selected-mark { color: color-mix(in srgb, var(--item-accent), #5b3a30 22%); }
/* `:not(.organ-card-image)` keeps the clickable artwork out of the footer
button styling — both are direct children of the card. */
.view-all, .learning-cards article > button:not(.organ-card-image) {
Expand Down Expand Up @@ -248,6 +250,59 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.tool-button.active { color: var(--coral); background: rgba(238,124,106,.08); }
.tool-button:nth-last-child(1) { border-top: 1px solid var(--line); border-radius: 0 0 12px 12px; }

/* Sits along the bottom of the viewer, between the caption and the auto-rotate
switch, and only while the cross-section tool is open. */
.section-controls {
position: absolute;
z-index: 3;
left: 50%;
bottom: 54px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 15px;
background: rgba(255,251,244,.9);
backdrop-filter: blur(18px);
box-shadow: 0 12px 30px rgba(75,54,40,.1);
animation: section-in .32s ease both;
}
@keyframes section-in { from { opacity: 0; transform: translate(-50%, 8px); } }
.section-axes { display: flex; gap: 2px; }
.section-axes button {
border: 0; border-radius: 9px; padding: 6px 10px; background: transparent;
cursor: pointer; color: var(--muted); font: 500 11px/1 var(--font-serif), serif;
}
.section-axes button.active { color: var(--coral); background: rgba(238,124,106,.1); }
.section-depth { display: flex; align-items: center; gap: 8px; }
.section-depth span { color: var(--muted); font: 500 10px/1 var(--font-serif), serif; letter-spacing: .06em; text-transform: uppercase; }
.section-depth input {
width: 132px; height: 4px; margin: 0; padding: 0;
appearance: none; border-radius: 3px; cursor: pointer;
background: linear-gradient(90deg, color-mix(in srgb, var(--coral), transparent 55%), var(--coral));
}
.section-depth input::-webkit-slider-thumb {
appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: #fffdf9; border: 2px solid var(--coral); cursor: pointer;
box-shadow: 0 2px 5px rgba(93,69,43,.22);
}
.section-depth input::-moz-range-thumb {
width: 15px; height: 15px; border-radius: 50%;
background: #fffdf9; border: 2px solid var(--coral); cursor: pointer;
}
.section-flip {
display: grid; place-items: center; width: 30px; height: 30px;
border: 1px solid var(--line); border-radius: 9px; background: transparent;
cursor: pointer; color: var(--muted);
}
.section-flip.active { color: var(--coral); border-color: color-mix(in srgb, var(--coral), transparent 60%); background: rgba(238,124,106,.09); }
.section-plane { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.section-plane input { width: 14px; height: 14px; margin: 0; accent-color: var(--coral); cursor: pointer; }
.section-plane span { color: var(--muted); font: 500 10px/1 var(--font-serif), serif; letter-spacing: .06em; text-transform: uppercase; }
.section-controls em { max-width: 150px; color: var(--muted); font: italic 11px/1.3 var(--font-serif), serif; }

.tip-note {
position: absolute;
right: 16px;
Expand Down Expand Up @@ -535,6 +590,15 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs
.learning-cards article { border-radius: 20px; }
.curiosity-card { display: none !important; }
.viewer-tools { top: auto; bottom: 12px; left: 10px; right: 10px; transform: none; width: auto; height: 61px; flex-direction: row; justify-content: space-around; padding: 4px; border-radius: 15px; }
/* Stacks above the caption/auto-rotate row, which itself sits above the tool
bar at this width. The plane hint is the first thing to go — the labels
already carry it. */
.section-controls { bottom: 128px; left: 10px; right: 10px; transform: none; gap: 8px; padding: 8px 10px; justify-content: space-between; }
.section-controls em { display: none; }
.section-axes button { padding: 6px 7px; font-size: 10px; }
.section-depth span { display: none; }
.section-depth input { width: 88px; }
@keyframes section-in { from { opacity: 0; transform: translateY(8px); } }
.tool-button { width: 51px; height: 51px; }
.tool-button:nth-last-child(1) { border: 0; border-radius: 12px; }
.tool-button span { display: none; }
Expand Down
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const OG_IMAGE = {
url: "/og.jpg",
width: 1200,
height: 675,
alt: "An anatomical heart specimen floating above a plinth, beside the Anatomy Atelier wordmark",
alt: "An anatomical heart specimen floating in warm light, beside the Anatomy Atelier wordmark",
};

/**
Expand Down
13 changes: 11 additions & 2 deletions app/lib/three/hotspots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,15 @@ export class HotspotLayer {
* ring. Returns false while values are still easing so the viewer knows it
* has to schedule another frame.
*/
update(camera: THREE.Camera, delta: number, selectedId: string | null, hoveredId: string | null) {
update(
camera: THREE.Camera,
delta: number,
selectedId: string | null,
hoveredId: string | null,
/** Cutting plane, in the same space as the anchors. Markers on the half
* being taken away fade out with it instead of floating in the gap. */
clip: THREE.Plane | null = null,
) {
if (!this.markers.length) return true;
this.time += delta;
this.group.updateWorldMatrix(true, false);
Expand Down Expand Up @@ -217,7 +225,8 @@ export class HotspotLayer {
const radius = this.outward.length();
this.toCamera.copy(camera.position).sub(this.world).normalize();
const facing = radius > 1e-4 ? this.outward.divideScalar(radius).dot(this.toCamera) : 1;
const target = THREE.MathUtils.smoothstep(facing, -0.05, 0.3);
const clipped = clip !== null && clip.distanceToPoint(marker.anchor) < 0;
const target = clipped ? 0 : THREE.MathUtils.smoothstep(facing, -0.05, 0.3);

const active = marker.hotspot.id === selectedId || marker.hotspot.id === hoveredId;
const emphasisTarget = active ? 1 : 0;
Expand Down
Loading