- {naturalCounterpart.name}
+ {naturalCounterpart.name}
natural
diff --git a/src/components/minerals/CrystalStructureCard.astro b/src/components/minerals/CrystalStructureCard.astro
index efae089..57f29cd 100644
--- a/src/components/minerals/CrystalStructureCard.astro
+++ b/src/components/minerals/CrystalStructureCard.astro
@@ -31,7 +31,7 @@ const forms = mineral.cdl ? parseForms(mineral.cdl) : [];
-
+
{mineral.cdl}
diff --git a/src/components/minerals/MineralCrystalViewer.test.tsx b/src/components/minerals/MineralCrystalViewer.test.tsx
new file mode 100644
index 0000000..ce6e0af
--- /dev/null
+++ b/src/components/minerals/MineralCrystalViewer.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MineralCrystalViewer } from './MineralCrystalViewer';
+import * as webgl from '../../lib/webgl';
+
+// Crystal3DViewer pulls in @react-three/fiber/three, which needs a real WebGL
+// context — stub it so these tests stay focused on MineralCrystalViewer's own
+// wiring (SVG-default, WebGL gate, lazy parse).
+vi.mock('../crystal/Crystal3DViewer', () => ({
+ Crystal3DViewer: ({ gltfData }: { gltfData: object | null }) => (
+
{gltfData ? 'rendered' : 'no-data'}
+ ),
+}));
+
+const VALID_GLTF = JSON.stringify({ buffers: [], accessors: [], bufferViews: [], meshes: [] });
+
+describe('MineralCrystalViewer', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders the inline SVG by default and shows no toggle without a model', () => {
+ vi.spyOn(webgl, 'isWebGLAvailable').mockReturnValue(true);
+ render(
+
+ );
+ expect(screen.getByRole('img', { name: 'Diamond crystal structure' })).toBeInTheDocument();
+ expect(screen.queryByText('3D WebGL')).not.toBeInTheDocument();
+ });
+
+ it('falls back to the file-based
![]()
when there is no inline SVG, with a guarded onerror', () => {
+ render(
+
+ );
+ const img = screen.getByAltText('Missing crystal structure') as HTMLImageElement;
+ expect(img.src).toContain('/crystals/missing.svg');
+
+ fireEvent.error(img);
+ expect(img.onerror).toBeNull();
+ expect(img.src).toContain('/crystals/placeholder.svg');
+
+ // A second error (e.g. placeholder.svg itself 404ing) must not loop.
+ fireEvent.error(img);
+ expect(img.src).toContain('/crystals/placeholder.svg');
+ });
+
+ it('hides the toggle when a model exists but WebGL is unavailable', () => {
+ vi.spyOn(webgl, 'isWebGLAvailable').mockReturnValue(false);
+ render(
+
+ );
+ expect(screen.queryByText('3D WebGL')).not.toBeInTheDocument();
+ });
+
+ it('shows the toggle and lazily parses the glTF only once switched to 3D', () => {
+ vi.spyOn(webgl, 'isWebGLAvailable').mockReturnValue(true);
+ vi.spyOn(webgl, 'prefersReducedMotion').mockReturnValue(false);
+ render(
+
+ );
+
+ expect(screen.queryByTestId('crystal-3d-viewer')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('3D WebGL'));
+ expect(screen.getByTestId('crystal-3d-viewer')).toHaveTextContent('rendered');
+ });
+
+ it('falls back to 2D without crashing when the glTF JSON is invalid', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(webgl, 'isWebGLAvailable').mockReturnValue(true);
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('3D WebGL'));
+
+ // Recovers to the 2D SVG rather than getting stuck on an empty 3D pane.
+ expect(screen.getByRole('img', { name: 'Broken crystal structure' })).toBeInTheDocument();
+ expect(consoleError).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/minerals/MineralCrystalViewer.tsx b/src/components/minerals/MineralCrystalViewer.tsx
new file mode 100644
index 0000000..87de504
--- /dev/null
+++ b/src/components/minerals/MineralCrystalViewer.tsx
@@ -0,0 +1,146 @@
+/**
+ * MineralCrystalViewer - SVG-first crystal preview with progressive 3D enhancement
+ *
+ * Renders the server-provided SVG (or the file-based fallback image) as the
+ * default, no-JS-safe view inside the gem page's specimen plate. When a
+ * pre-generated glTF model is present AND WebGL is available client-side,
+ * a
appears so visitors can switch to an interactive 3D
+ * render (Crystal3DViewer). The glTF JSON string is only parsed lazily, the
+ * first time the visitor switches to 3D, and the parsed object is cached so
+ * toggling back and forth doesn't reparse.
+ *
+ * Reference wiring pattern: FamilyModal.tsx (lazy JSON.parse on 3D switch)
+ * and MineralModal.tsx (specimen-plate + ViewerToggle layout).
+ */
+import { useEffect, useRef, useState } from 'react';
+import { Crystal3DViewer } from '../crystal/Crystal3DViewer';
+import { ViewerToggle } from '../crystal/ViewerToggle';
+import { isWebGLAvailable, prefersReducedMotion } from '../../lib/webgl';
+
+type ViewMode = '2d' | '3d';
+
+interface MineralCrystalViewerProps {
+ /** Cleaned inline SVG markup (may be empty — falls back to svgPath). */
+ svgHtml?: string;
+ /** File-based SVG fallback, e.g. /crystals/{id}.svg */
+ svgPath: string;
+ /** Pre-generated glTF model as a JSON string, or null/undefined if none exists. */
+ gltfJson?: string | null;
+ name: string;
+}
+
+export function MineralCrystalViewer({
+ svgHtml = '',
+ svgPath,
+ gltfJson = null,
+ name,
+}: MineralCrystalViewerProps) {
+ const [isClient, setIsClient] = useState(false);
+ const [canRender3D, setCanRender3D] = useState(false);
+ const [viewMode, setViewMode] = useState('2d');
+ const [gltfData, setGltfData] = useState
-
+
-
- {hasSvgContent ? (
-
- ) : (
-

- )}
-
+
@@ -112,8 +104,9 @@ function formatLabel(value: string | null | undefined): string {
{mineral.chemistry && (
-
- {mineral.chemistry}
+
+ Formula
+ {mineral.chemistry}
)}
diff --git a/src/components/minerals/MineralTagsCard.astro b/src/components/minerals/MineralTagsCard.astro
new file mode 100644
index 0000000..a79523c
--- /dev/null
+++ b/src/components/minerals/MineralTagsCard.astro
@@ -0,0 +1,69 @@
+---
+/**
+ * MineralTagsCard - Consolidates the mineral's JSON-array tag lists
+ * (colours, localities, inclusions, treatments, crystal forms) into a
+ * single card with labelled sub-rows, instead of one card per list.
+ */
+import TagList from './TagList.astro';
+import type { Mineral } from '../../lib/db';
+
+interface Props {
+ mineral: Mineral;
+}
+
+const { mineral } = Astro.props;
+
+function parseCount(json?: string | null): number {
+ if (!json) return 0;
+ try {
+ const parsed = JSON.parse(json);
+ return Array.isArray(parsed) ? parsed.length : 0;
+ } catch {
+ return 0;
+ }
+}
+
+const rows = [
+ {
+ label: 'Colours',
+ json: mineral.colors_json,
+ colorClass: 'bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300',
+ },
+ {
+ label: 'Localities',
+ json: mineral.localities_json,
+ colorClass: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300',
+ },
+ {
+ label: 'Common Inclusions',
+ json: mineral.inclusions_json,
+ colorClass: 'bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300',
+ },
+ {
+ label: 'Known Treatments',
+ json: mineral.treatments_json,
+ colorClass: 'bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300',
+ },
+ {
+ label: 'Crystal Forms',
+ json: mineral.forms_json,
+ colorClass: 'bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-300',
+ },
+].filter((row) => parseCount(row.json) > 0);
+---
+
+{rows.length > 0 && (
+
+
+
Characteristics
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+)}
diff --git a/src/components/minerals/PropertyTable.astro b/src/components/minerals/PropertyTable.astro
deleted file mode 100644
index 10ed10c..0000000
--- a/src/components/minerals/PropertyTable.astro
+++ /dev/null
@@ -1,36 +0,0 @@
----
-/**
- * PropertyTable - Displays a table of mineral properties
- */
-
-interface Property {
- label: string;
- value: string | null | undefined;
-}
-
-interface Props {
- title: string;
- properties: Property[];
-}
-
-const { title, properties } = Astro.props;
-
-// Filter out properties without values
-const validProperties = properties.filter(p => p.value);
----
-
-{validProperties.length > 0 && (
-
-
-
{title}
-
-
- {validProperties.map((property) => (
-
- {property.label}
- {property.value}
-
- ))}
-
-
-)}
diff --git a/src/components/minerals/RelatedMinerals.astro b/src/components/minerals/RelatedMinerals.astro
index e6aac26..272dfa3 100644
--- a/src/components/minerals/RelatedMinerals.astro
+++ b/src/components/minerals/RelatedMinerals.astro
@@ -31,7 +31,7 @@ const { minerals, currentSystem } = Astro.props;
return (
{hasSvg ? (
@@ -45,12 +45,12 @@ const { minerals, currentSystem } = Astro.props;
alt={mineral.name}
class="w-full h-full object-contain"
loading="lazy"
- onerror="this.src='/crystals/placeholder.svg'"
+ onerror="this.onerror=null; this.src='/crystals/placeholder.svg'"
/>
)}
-
{mineral.name}
+
{mineral.name}
{mineral.chemistry && (
{mineral.chemistry}
)}
diff --git a/src/components/minerals/TagList.astro b/src/components/minerals/TagList.astro
index 1f5a88c..8139896 100644
--- a/src/components/minerals/TagList.astro
+++ b/src/components/minerals/TagList.astro
@@ -1,26 +1,39 @@
---
/**
* TagList - Renders a JSON string array as coloured tag pills
+ *
+ * By default renders as a standalone card (title + pill row). Pass `bare`
+ * to render only the pill row with no outer card chrome or heading — used
+ * when embedding inside another card shell (see MineralTagsCard.astro).
*/
interface Props {
title: string;
json?: string | null;
colorClass?: string;
+ bare?: boolean;
}
-const { title, json, colorClass = 'bg-slate-100 text-slate-700 dark:bg-coffee-raised2 dark:text-cream-secondary' } = Astro.props;
+const { title, json, colorClass = 'bg-slate-100 text-slate-700 dark:bg-coffee-raised2 dark:text-cream-secondary', bare = false } = Astro.props;
const items: string[] = json ? JSON.parse(json) : [];
---
{items.length > 0 && (
-
-
-
{title}
-
-
+ bare ? (
+
{items.map((item) => (
{item}
))}
-
+ ) : (
+
+
+
{title}
+
+
+ {items.map((item) => (
+ {item}
+ ))}
+
+
+ )
)}
diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx
index b703de2..41eae64 100644
--- a/src/components/ui/Table.tsx
+++ b/src/components/ui/Table.tsx
@@ -174,14 +174,14 @@ export function PropertyTable({
{title}
-
+
{validProperties.map((property, index) => (
-
-
{property.label}
-
{property.value}
+
+
- {property.label}
+ - {property.value}
))}
-
+
);
}
diff --git a/src/lib/db-server.ts b/src/lib/db-server.ts
index 184a831..63b7744 100644
--- a/src/lib/db-server.ts
+++ b/src/lib/db-server.ts
@@ -150,14 +150,21 @@ export async function getMineralWithModels(name: string): Promise
family_id)
+ // Resolve family_id AND the 3D model from mineral_expressions. The base
+ // `minerals` table's model_gltf/model_svg columns are empty — the populated
+ // model data lives per-expression in mineral_expressions, so pull model_gltf
+ // from there (otherwise the detail page's 3D viewer never receives a model).
const exprResult = database.exec(
- `SELECT family_id FROM mineral_expressions WHERE id = ? LIMIT 1`,
+ `SELECT family_id, model_gltf FROM mineral_expressions WHERE id = ? LIMIT 1`,
[mineral.id as string]
);
- const familyId = exprResult.length > 0 && exprResult[0].values.length > 0
- ? exprResult[0].values[0][0] as string
- : mineral.id as string;
+ let familyId = mineral.id as string;
+ if (exprResult.length > 0 && exprResult[0].values.length > 0) {
+ const exprRow = exprResult[0].values[0];
+ familyId = (exprRow[0] as string) ?? (mineral.id as string);
+ const exprGltf = exprRow[1] as string | null;
+ if (exprGltf) mineral.model_gltf = exprGltf;
+ }
mineral.family_id = familyId;
// Fetch family-level fields (fluorescence, category, diagnostic_features, common_inclusions)
diff --git a/src/pages/minerals/[slug].astro b/src/pages/minerals/[slug].astro
index 1dcb33a..a6503ac 100644
--- a/src/pages/minerals/[slug].astro
+++ b/src/pages/minerals/[slug].astro
@@ -6,13 +6,13 @@
import BaseLayout from '../../layouts/BaseLayout.astro';
import MineralHero from '../../components/minerals/MineralHero.astro';
import QuickFacts from '../../components/minerals/QuickFacts.astro';
-import PropertyTable from '../../components/minerals/PropertyTable.astro';
+import { PropertyTable } from '../../components/ui/Table';
import CrystalStructureCard from '../../components/minerals/CrystalStructureCard.astro';
import RelatedMinerals from '../../components/minerals/RelatedMinerals.astro';
import CounterpartsSection from '../../components/minerals/CounterpartsSection.astro';
import PlaygroundLink from '../../components/minerals/PlaygroundLink.astro';
import LearnMoreSection from '../../components/minerals/LearnMoreSection.astro';
-import TagList from '../../components/minerals/TagList.astro';
+import MineralTagsCard from '../../components/minerals/MineralTagsCard.astro';
import MineralSchema from '../../components/seo/MineralSchema.astro';
import { getAllMinerals, getAllFamilies, getPrimaryExpressionId, getMineralWithModels, getMineralsBySystemWithSvg, getCounterpartMinerals, getNaturalCounterpart, type Mineral } from '../../lib/db-server';
import { formatLabel } from '../../lib/format-label';
@@ -148,12 +148,8 @@ const canonicalUrl = `https://gemmology.dev/minerals/${mineral.id}`;
/>
)}
- {/* JSON array sections */}
-
-
-
-
-
+ {/* Consolidated tag lists (colours, localities, inclusions, treatments, forms) */}
+
{/* Diagnostic Features (family-level) */}
{mineral.diagnostic_features && (