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
168 changes: 91 additions & 77 deletions app/components/AnatomyApp.tsx

Large diffs are not rendered by default.

77 changes: 56 additions & 21 deletions app/components/OrganViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
CircleDashed,
Expand All @@ -14,6 +14,7 @@ import {
} from "lucide-react";
import type { Hotspot, Organ } from "../lib/anatomy-data";
import type { AnatomyViewer } from "../lib/three/viewer";
import { useI18n } from "../i18n/useI18n";

type Props = {
organ: Organ;
Expand All @@ -24,6 +25,7 @@ type Props = {
};

export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompare }: Props) {
const { t, locale } = useI18n();
const mountRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<AnatomyViewer | null>(null);
const organRef = useRef(organ);
Expand Down Expand Up @@ -58,7 +60,16 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
void import("../lib/three/viewer").then(({ AnatomyViewer: Viewer }) => {
if (cancelled || !mountRef.current) return;
viewer = new Viewer(mountRef.current, {
onSelect: setSelected,
onSelect: (hotspot) => {
if (!hotspot) {
setSelected(null);
return;
}
// Resolve labels from the live locale catalog so switching language
// does not require reloading the GLB.
const localized = organRef.current.hotspots.find((item) => item.id === hotspot.id);
setSelected(localized ?? hotspot);
},
onLoading: (isLoading, value) => {
setLoading(isLoading);
setProgress(value);
Expand All @@ -67,6 +78,7 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
});
viewerRef.current = viewer;
viewer.setAutoRotate(autoRotateRef.current);
viewer.setAriaLabel(t("viewer.canvasAria"));
const current = organRef.current;
viewer.setOrgan(current.model, current.hotspots, current.accent).catch(() => {
setLoading(false);
Expand All @@ -79,17 +91,33 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
viewerRef.current = null;
viewer?.dispose();
};
// Intentionally mount-once; locale aria updates via a dedicated effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
viewerRef.current?.setOrgan(organ.model, organ.hotspots, organ.accent).catch(() => {
setLoading(false);
setProgress(0);
});
}, [organ]);
// Reload only when the specimen changes — not when locale copy updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organ.id, organ.model, organ.accent]);

useEffect(() => viewerRef.current?.setAutoRotate(autoRotate), [autoRotate]);

useEffect(() => {
viewerRef.current?.setAriaLabel(t("viewer.canvasAria"));
}, [locale, t]);

// Keep the selected hotspot's copy in sync when the locale changes.
useEffect(() => {
setSelected((current) => {
if (!current) return null;
return organ.hotspots.find((hotspot) => hotspot.id === current.id) ?? null;
});
}, [organ]);

// The viewer drives the callout's position directly, so a spinning model
// never costs a React render.
const calloutRef = useCallback((node: HTMLDivElement | null) => {
Expand All @@ -111,22 +139,27 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
}
};

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 },
];
const tools = useMemo(
() => [
{ id: "rotate", label: t("viewer.rotate"), icon: RotateCcw },
{ id: "zoom", label: t("viewer.zoom"), icon: Search },
{ id: "isolate", label: t("viewer.isolate"), icon: CircleDashed },
{ id: "section", label: t("viewer.section"), icon: ScanLine },
{ id: "layers", label: t("viewer.layers"), icon: Layers3 },
{ id: "compare", label: t("viewer.compare"), icon: Box },
{ id: "reset", label: t("viewer.reset"), icon: RotateCcw },
],
[t],
);

const tipLines = t("viewer.tipBody").split("\n");

return (
<section className="viewer-shell" aria-label={`${organ.name} interactive viewer`}>
<section className="viewer-shell" aria-label={t("viewer.label", { name: organ.name })}>
<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">
<div className="viewer-tools" aria-label={t("viewer.tools")}>
{tools.map(({ id, label, icon: Icon }) => (
<button
key={id}
Expand All @@ -142,15 +175,17 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
))}
</div>

<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>
<aside className="tip-note" aria-label={t("viewer.tip")}>
<span><Sparkles size={15} /> {t("viewer.tip")}</span>
<p>{tipLines.map((line, index) => (
<span key={line}>{index > 0 ? <br /> : null}{line}</span>
))}</p>
</aside>

{selected && (
<div className="hotspot-callout" ref={calloutRef} data-side="right">
<div className="callout-body" style={{ "--hotspot-color": selected.color } as React.CSSProperties}>
<button className="callout-close" type="button" onClick={() => viewerRef.current?.clearSelection()} aria-label="Close">
<button className="callout-close" type="button" onClick={() => viewerRef.current?.clearSelection()} aria-label={t("a11y.close")}>
<X size={13} />
</button>
<b>{selected.label}</b>
Expand All @@ -169,18 +204,18 @@ export function OrganViewer({ organ, autoRotate, onAutoRotate, compare, onCompar
{loading && slowLoad && (
<div className="model-loader" role="status" aria-live="polite">
<div className="loader-orbit"><Maximize2 size={20} /></div>
<strong>Preparing the {organ.name.toLowerCase()}</strong>
<strong>{t("viewer.preparing", { name: organ.name.toLowerCase() })}</strong>
<span>{Math.max(8, Math.round(progress * 100))}%</span>
</div>
)}

<button className="auto-rotate" type="button" onClick={() => onAutoRotate(!autoRotate)} aria-pressed={autoRotate}>
<RotateCcw size={14} /> Auto rotate
<RotateCcw size={14} /> {t("viewer.autoRotate")}
<span className={`switch ${autoRotate ? "on" : ""}`}><i /></span>
</button>

<div className="view-caption">
<span>3D specimen · click a dot to explore</span>
<span>{t("viewer.caption")}</span>
<strong>{organ.scientificName}</strong>
</div>
</section>
Expand Down
63 changes: 48 additions & 15 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.topbar {
height: 68px;
display: grid;
grid-template-columns: minmax(300px, 1fr) auto minmax(260px, 1fr) auto;
grid-template-columns: minmax(280px, 1fr) auto minmax(200px, 1fr) auto auto auto;
align-items: center;
gap: 22px;
gap: 18px;
max-width: 1720px;
margin: 0 auto;
}
Expand Down Expand Up @@ -112,6 +112,33 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 12px; }
.search-box input::placeholder { color: #a49b93; }

.language-switcher {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
}

.language-switcher button {
border: 0;
background: transparent;
padding: 7px 11px;
border-radius: 999px;
font: 600 11px / 1 var(--font-sans), sans-serif;
letter-spacing: 0.06em;
color: var(--muted);
cursor: pointer;
}

.language-switcher button.active {
background: rgba(238, 124, 106, 0.12);
color: var(--ink);
box-shadow: inset 0 0 0 1px rgba(215, 112, 91, 0.12);
}

.profile, .mobile-library-trigger {
display: flex;
align-items: center;
Expand Down Expand Up @@ -161,7 +188,8 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
}
.panel-heading button { border: 1px solid var(--line); background: rgba(255,255,255,.3); width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; cursor: pointer; }
.panel-heading .mobile-close { display: none; }
.organ-list { display: flex; flex-direction: column; gap: 4px; min-height: 0; overflow: auto; scrollbar-width: none; }
.organ-list { display: flex; flex-direction: column; gap: 4px; min-height: 0; overflow: auto; -ms-overflow-style: none; }
.organ-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
.organ-item {
width: 100%;
min-height: 70px;
Expand All @@ -176,7 +204,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
background: transparent;
cursor: pointer;
}
.organ-item.active { background: rgba(253,234,226,.72); border-color: color-mix(in srgb, var(--item-accent), transparent 55%); box-shadow: 0 8px 20px rgba(195,91,70,.07); }
.organ-item.active { background: rgba(253,234,226,.72); border-color: var(--item-accent); box-shadow: 0 8px 20px rgba(195,91,70,.07); }
.organ-glyph {
width: 47px;
height: 47px;
Expand Down Expand Up @@ -241,6 +269,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(253,250,244,.82);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
box-shadow: 0 12px 30px rgba(75,54,40,.08);
}
Expand Down Expand Up @@ -283,6 +312,7 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
border: 1px solid rgba(116,89,67,.14);
border-radius: 13px;
background: rgba(255,252,247,.92);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hotspot-color), transparent 78%), 0 12px 28px rgba(83,59,42,.12);
animation: callout-in .26s cubic-bezier(.2,.85,.3,1);
Expand Down Expand Up @@ -334,13 +364,14 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
.view-caption { position: absolute; left: 98px; bottom: 17px; display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; }
.view-caption strong { color: var(--ink); font: 500 13px var(--font-serif), serif; text-transform: none; letter-spacing: 0; }

.model-loader { position: absolute; inset: 0; z-index: 6; display: grid; place-content: center; justify-items: center; gap: 8px; background: rgba(251,246,238,.45); backdrop-filter: blur(4px); }
.model-loader { position: absolute; inset: 0; z-index: 6; display: grid; place-content: center; justify-items: center; gap: 8px; background: rgba(251,246,238,.45); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
.model-loader strong { font: 500 18px var(--font-serif), serif; }
.model-loader span { color: var(--muted); font-size: 10px; }
.loader-orbit { width: 48px; height: 48px; border: 1px dashed var(--coral); border-radius: 50%; display: grid; place-items: center; color: var(--coral); animation: orbit 2.4s linear infinite; }
@keyframes orbit { to { transform: rotate(360deg); } }

.info-panel { padding: 21px 20px 17px; overflow: auto; scrollbar-width: none; }
.info-panel { padding: 21px 20px 17px; overflow: auto; -ms-overflow-style: none; }
.info-panel::-webkit-scrollbar { display: none; width: 0; height: 0; }
.info-kicker { display: flex; align-items: center; gap: 7px; color: #795c55; }
.info-kicker svg { color: #ee8a7a; }
.info-title-row { display: flex; justify-content: space-between; align-items: flex-start; margin-top: 15px; }
Expand Down Expand Up @@ -403,8 +434,8 @@ button:focus-visible, input:focus-visible, canvas:focus-visible {
width: 100%;
height: 100%;
border-radius: inherit;
background: radial-gradient(circle at 40% 32%, #fff, color-mix(in srgb, var(--art-accent), white 84%));
color: color-mix(in srgb, var(--art-accent), #5f4034 26%);
background: radial-gradient(circle at 40% 32%, #fff 18%, #f3ebe3 100%);
color: var(--art-accent);
font-family: var(--font-serif), serif;
font-size: 34px;
line-height: 1;
Expand Down Expand Up @@ -444,7 +475,7 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs
pushed down by an auto top margin. */
.system-visual { overflow: hidden; border-radius: 12px; }

.compare-strip { position: fixed; z-index: 20; left: 50%; bottom: 22px; transform: translateX(-50%); width: min(760px, calc(100% - 28px)); padding: 13px 16px; display: grid; grid-template-columns: 1fr auto 1fr 1.5fr auto; align-items: center; gap: 14px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,251,245,.94); backdrop-filter: blur(18px); box-shadow: 0 22px 60px rgba(64,43,29,.2); }
.compare-strip { position: fixed; z-index: 20; left: 50%; bottom: 22px; transform: translateX(-50%); width: min(760px, calc(100% - 28px)); padding: 13px 16px; display: grid; grid-template-columns: 1fr auto 1fr 1.5fr auto; align-items: center; gap: 14px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,251,245,.94); -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); box-shadow: 0 22px 60px rgba(64,43,29,.2); }
.compare-strip > div { display: flex; flex-direction: column; }
.compare-strip .compare-organ { position: relative; padding-left: 45px; min-height: 38px; justify-content: center; }
.compare-organ > img { position: absolute; left: 0; top: 50%; width: 38px; height: 38px; border-radius: 50%; object-fit: cover; transform: translateY(-50%); mix-blend-mode: multiply; }
Expand All @@ -457,8 +488,9 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs
.compare-strip dd { margin: 3px 0 0; font: 500 10px/1.2 var(--font-serif), serif; }
.compare-strip > button { border: 0; background: transparent; cursor: pointer; }

.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 20px; background: rgba(52,39,30,.24); backdrop-filter: blur(8px); }
.learning-modal { width: min(460px, 100%); max-height: min(92vh, 900px); overflow: auto; scrollbar-width: none; position: relative; padding: 32px; border: 1px solid var(--line); border-radius: 26px; background: #fffaf2; box-shadow: 0 30px 90px rgba(63,42,28,.24); text-align: center; animation: modal-in .35s cubic-bezier(.2,.85,.3,1); }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 20px; background: rgba(52,39,30,.24); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.learning-modal { width: min(460px, 100%); max-height: min(92vh, 900px); overflow: auto; -ms-overflow-style: none; position: relative; padding: 32px; border: 1px solid var(--line); border-radius: 26px; background: #fffaf2; box-shadow: 0 30px 90px rgba(63,42,28,.24); text-align: center; animation: modal-in .35s cubic-bezier(.2,.85,.3,1); }
.learning-modal::-webkit-scrollbar { display: none; width: 0; height: 0; }
.learning-modal.wide { width: min(540px, 100%); }
@keyframes modal-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
.modal-close { position: absolute; top: 13px; right: 13px; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; }
Expand All @@ -485,15 +517,15 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs

@media (max-width: 1350px) {
.app-shell { padding: 16px; }
.topbar { grid-template-columns: 1fr auto auto; gap: 12px; }
.topbar { grid-template-columns: 1fr auto auto auto; gap: 12px; }
.brand em { display: none; }
.search-box { display: none; }
.workspace { grid-template-columns: 250px minmax(500px,1fr) 300px; height: min(700px, calc(100vh - 105px)); }
.learning-cards { grid-template-columns: repeat(3, 1fr); --art-max: 300px; }
}

@media (max-width: 1040px) {
.topbar { grid-template-columns: 1fr auto auto; }
.topbar { grid-template-columns: 1fr auto auto auto; }
.main-nav button span { display: none; }
.main-nav button { padding: 10px; }
.workspace { grid-template-columns: 210px minmax(450px,1fr); height: 690px; }
Expand All @@ -507,9 +539,10 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs

@media (max-width: 760px) {
.app-shell { padding: 10px; overflow: hidden; }
.topbar { height: 58px; grid-template-columns: 1fr auto auto; }
.topbar { height: 58px; grid-template-columns: 1fr auto auto auto; }
.brand strong { font-size: 29px; }
.main-nav { display: none; }
.language-switcher button { padding: 6px 9px; }
.profile span { width: 34px; height: 34px; }
.mobile-library-trigger { display: flex; }
.workspace { display: block; margin-top: 8px; }
Expand All @@ -527,7 +560,7 @@ button.organ-card-image:focus-visible { outline: 2px solid #8b6fc4; outline-offs
}
.organ-library.open { transform: translateY(0); }
.panel-heading .mobile-close { display: grid; margin-left: auto; margin-right: 7px; }
.drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 40; border: 0; background: rgba(52,39,30,.24); backdrop-filter: blur(5px); }
.drawer-backdrop { display: block; position: fixed; inset: 0; z-index: 40; border: 0; background: rgba(52,39,30,.24); -webkit-backdrop-filter: blur(5px); backdrop-filter: blur(5px); }
.organ-list { display: grid; grid-template-columns: 1fr 1fr; }
.organ-item { min-height: 76px; }
.info-panel { height: auto; margin-top: 10px; display: block; border-radius: 22px; }
Expand Down
61 changes: 61 additions & 0 deletions app/i18n/I18nProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { organByIdFrom, type Organ, type OrganId } from "../lib/anatomy-data";
import { DEFAULT_LOCALE, LOCALES, type Locale } from "./config";
import { getOrgansForLocale, getUiForLocale, messages } from "./messages";
import { readStoredLocale, writeStoredLocale } from "./storage";
import { translate } from "./translate";

type TranslateFn = (key: string, params?: Record<string, string | number>) => string;

type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
locales: readonly Locale[];
t: TranslateFn;
organs: Organ[];
organById: Record<OrganId, Organ>;
};

export const I18nContext = createContext<I18nContextValue | null>(null);

export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);

useEffect(() => {
setLocaleState(readStoredLocale());
}, []);

useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);

const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
writeStoredLocale(next);
}, []);

const value = useMemo<I18nContextValue>(() => {
const ui = getUiForLocale(locale);
const fallbackUi = messages[DEFAULT_LOCALE].ui;
const organs = getOrgansForLocale(locale);
return {
locale,
setLocale,
locales: LOCALES,
t: (key, params) => translate(ui, key, params, fallbackUi),
organs,
organById: organByIdFrom(organs),
};
}, [locale, setLocale]);

return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
Loading