Skip to content
Merged
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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"build": "astro build",
"preview": "astro preview",
"copy-db": "node scripts/copy-database.js",
"prerender": "node scripts/prerender-crystals.js",
"check-svgs": "node scripts/audit-crystal-svgs.mjs",
"prebuild": "npm run sync && npm run copy-db && npm run check-svgs",
"generate-og": "npm run build && node scripts/generate-og-image.js",
Expand Down
6 changes: 6 additions & 0 deletions public/crystals/garnet-topped-doublet-doublet.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/glass-simulant-faceted.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/opal-doublet-cabochon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/opal-triplet-cabochon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/placeholder.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/soude-emerald-doublet.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/synthetic-coral-cabochon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/crystals/synthetic-lapis-gilson-cabochon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions public/crystals/synthetic-opal-gilson-cabochon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 32 additions & 8 deletions scripts/audit-crystal-svgs.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
/**
* Verify that every mineral_families row has a corresponding SVG in public/crystals/.
* Verify that every mineral_families row AND every mineral_expressions row
* has a corresponding SVG — either a pre-generated `model_svg` in the
* database (expressions only) or a file in public/crystals/.
* Exits non-zero if any are missing.
*/
import { existsSync, readFileSync } from 'node:fs';
Expand All @@ -20,16 +22,38 @@ if (!existsSync(DB_PATH)) {

const SQL = await initSqlJs();
const db = new SQL.Database(readFileSync(DB_PATH));
const result = db.exec('SELECT id FROM mineral_families ORDER BY id');
const ids = result[0].values.map((row) => row[0]);

const missing = ids.filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`)));
// 1. Family-level check (existing behaviour).
const familyResult = db.exec('SELECT id FROM mineral_families ORDER BY id');
const familyIds = familyResult.length ? familyResult[0].values.map((row) => row[0]) : [];
const missingFamilies = familyIds.filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`)));

if (missing.length === 0) {
console.log(`OK: all ${ids.length} mineral families have SVGs in public/crystals/`);
// 2. Expression-level check: a gap only counts if there is NEITHER an inline
// `model_svg` in the database NOR a file in public/crystals/.
const expressionResult = db.exec('SELECT id, model_svg FROM mineral_expressions ORDER BY id');
const expressionRows = expressionResult.length ? expressionResult[0].values : [];
const missingExpressions = expressionRows
.filter(([, modelSvg]) => !(typeof modelSvg === 'string' && modelSvg.trim().length > 0))
.map(([id]) => id)
.filter((id) => !existsSync(join(CRYSTALS_DIR, `${id}.svg`)));

if (missingFamilies.length === 0 && missingExpressions.length === 0) {
console.log(
`OK: all ${familyIds.length} mineral families and ${expressionRows.length} mineral expressions have SVGs (file or model_svg) available`
);
process.exit(0);
}

console.error(`MISSING ${missing.length}/${ids.length} SVGs:`);
for (const id of missing) console.error(` - ${id}`);
if (missingFamilies.length > 0) {
console.error(`MISSING ${missingFamilies.length}/${familyIds.length} family SVGs:`);
for (const id of missingFamilies) console.error(` - ${id}`);
}

if (missingExpressions.length > 0) {
console.error(
`MISSING ${missingExpressions.length}/${expressionRows.length} expression SVGs (no model_svg and no file):`
);
for (const id of missingExpressions) console.error(` - ${id}`);
}

process.exit(1);
121 changes: 121 additions & 0 deletions scripts/generate-missing-crystals.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Generate placeholder SVGs for any mineral_expressions row that has neither
* a pre-generated `model_svg` in the database nor an existing file in
* public/crystals/.
*
* Strategy (in priority order), per missing expression:
* 1. Skip if public/crystals/{expression_id}.svg already exists (idempotent).
* 2. Copy the family-level placeholder public/crystals/{family_id}.svg if
* it exists — this is the common case, since most expression-level
* gaps are for materials whose family already has a generic outline.
* 3. Otherwise, emit an origin-appropriate template (composite/simulant/
* default) so the page never 404s on a broken <img> reference.
*
* Usage: node scripts/generate-missing-crystals.mjs
*/
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import initSqlJs from 'sql.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const CRYSTALS_DIR = join(ROOT, 'public', 'crystals');

const require = createRequire(import.meta.url);

function resolveDbPath() {
try {
const mineralData = require('@gemmology/mineral-data');
if (mineralData?.dbPath && existsSync(mineralData.dbPath)) {
return mineralData.dbPath;
}
} catch {
// fall through to direct node_modules path
}
const fallback = join(ROOT, 'node_modules', '@gemmology', 'mineral-data', 'minerals.db');
if (existsSync(fallback)) return fallback;
throw new Error(
`Could not locate @gemmology/mineral-data database. Run npm install first.`
);
}

// Origin-appropriate templates, mirroring the existing family-level placeholder
// conventions in public/crystals/ (viewBox 0 0 200 200, width/height 568.8pt).
const TEMPLATES = {
// Doublets/triplets/assembled stones: gray ellipse ("cabochon" silhouette),
// matching e.g. public/crystals/opal-doublet.svg, garnet-topped-doublet.svg.
composite: `<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="568.8pt" height="568.8pt">
<ellipse cx="100" cy="100" rx="80" ry="50" fill="#e2e8f0" stroke="#475569" stroke-width="1.5"/>
<ellipse cx="100" cy="100" rx="80" ry="50" fill="none" stroke="#475569" stroke-width="0.8" stroke-dasharray="2 2"/>
<line x1="20" y1="100" x2="180" y2="100" stroke="#334155" stroke-width="1"/>
</svg>
`,
// Simulants (glass, gilson-process, etc.): pale-blue faceted outline,
// matching e.g. public/crystals/glass-simulant.svg, synthetic-coral.svg.
simulant: `<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="568.8pt" height="568.8pt">
<polygon points="100,30 160,80 130,160 70,160 40,80" fill="#e0f2fe" stroke="#0369a1" stroke-width="1.5"/>
<polygon points="100,30 160,80 130,160 70,160 40,80" fill="none" stroke="#0369a1" stroke-width="0.5" stroke-dasharray="3 3"/>
<line x1="100" y1="30" x2="100" y2="160" stroke="#0369a1" stroke-width="0.5" opacity="0.3"/>
</svg>
`,
// Last-resort neutral-gray fallback (same content as public/crystals/placeholder.svg).
default: `<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="568.8pt" height="568.8pt">
<polygon points="100,30 160,80 130,160 70,160 40,80" fill="#e2e8f0" stroke="#64748b" stroke-width="1.5"/>
<polygon points="100,30 160,80 130,160 70,160 40,80" fill="none" stroke="#64748b" stroke-width="0.5" stroke-dasharray="3 3"/>
<line x1="100" y1="30" x2="100" y2="160" stroke="#64748b" stroke-width="0.5" opacity="0.3"/>
</svg>
`,
};

const DB_PATH = resolveDbPath();
const SQL = await initSqlJs();
const db = new SQL.Database(readFileSync(DB_PATH));

const expressionsResult = db.exec(
"SELECT id, family_id, model_svg FROM mineral_expressions ORDER BY id"
);
const expressions = expressionsResult.length
? expressionsResult[0].values.map(([id, family_id, model_svg]) => ({ id, family_id, model_svg }))
: [];

const familyOriginResult = db.exec('SELECT id, origin FROM mineral_families');
const originByFamily = new Map(
familyOriginResult.length ? familyOriginResult[0].values.map(([id, origin]) => [id, origin]) : []
);

let written = 0;
let skipped = 0;

for (const expr of expressions) {
const hasModelSvg = typeof expr.model_svg === 'string' && expr.model_svg.trim().length > 0;
if (hasModelSvg) continue;

const targetPath = join(CRYSTALS_DIR, `${expr.id}.svg`);
if (existsSync(targetPath)) {
skipped++;
continue;
}

const familySvgPath = join(CRYSTALS_DIR, `${expr.family_id}.svg`);
if (existsSync(familySvgPath)) {
copyFileSync(familySvgPath, targetPath);
console.log(`wrote ${expr.id}.svg (copied from family placeholder ${expr.family_id}.svg)`);
written++;
continue;
}

const origin = originByFamily.get(expr.family_id);
const template = TEMPLATES[origin] ?? TEMPLATES.default;
const templateName = TEMPLATES[origin] ? origin : 'default';
writeFileSync(targetPath, template);
console.log(`wrote ${expr.id}.svg (${templateName} template, origin=${origin ?? 'unknown'})`);
written++;
}

console.log(`\nDone. Wrote ${written} new SVG(s), skipped ${skipped} already-present file(s).`);
4 changes: 4 additions & 0 deletions src/components/crystal/ViewerToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export function ViewerToggle({
<button
onClick={() => onModeChange('2d')}
disabled={disabled}
aria-pressed={mode === '2d'}
aria-label="Show 2D crystal drawing"
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-150',
'flex items-center gap-1.5',
Expand All @@ -42,6 +44,8 @@ export function ViewerToggle({
<button
onClick={() => onModeChange('3d')}
disabled={disabled}
aria-pressed={mode === '3d'}
aria-label="Show interactive 3D crystal model"
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-150',
'flex items-center gap-1.5',
Expand Down
14 changes: 7 additions & 7 deletions src/components/minerals/CounterpartsSection.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const hasNaturalCounterpart = !!naturalCounterpart;

// Strip the parenthetical expression label (e.g. "Diamond (Octahedron)" → "Diamond")
// — synthetics list is family-scoped, so the expression name is misleading.
const familyName = mineral.name.replace(/\s*\([^)]*\)\s*$/, '').trim();
const familyName = mineral.name.replace(/\s*\(.*\)\s*$/, '').trim();

// Don't render if nothing to show
if (!hasCounterparts && !hasNaturalCounterpart) return;
Expand Down Expand Up @@ -60,7 +60,7 @@ const originStyles: Record<string, string> = {
return (
<a
href={`/minerals/${cp.id}`}
class="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors dark:hover:bg-coffee-raised2"
class="flex items-start gap-4 p-4 hover:bg-slate-50 transition-colors dark:hover:bg-coffee-raised2"
>
<div class="w-12 h-12 flex-shrink-0 bg-slate-50 rounded-lg p-1 overflow-hidden dark:border dark:border-coffee-border">
{hasSvg ? (
Expand All @@ -78,13 +78,13 @@ const originStyles: Record<string, string> = {
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-slate-900 truncate dark:text-cream-primary">{cp.name}</span>
<span class="font-medium text-slate-900 line-clamp-2 dark:text-cream-primary">{cp.name}</span>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded-full capitalize ${originStyle}`}>
{cp.origin}
</span>
</div>
{cp.growth_method && (
<div class="text-sm text-slate-600 dark:text-cream-secondary">{formatLabel(cp.growth_method)}</div>
<div class="text-sm text-slate-600 line-clamp-2 dark:text-cream-secondary">{formatLabel(cp.growth_method)}</div>
)}
</div>
<svg class="w-5 h-5 text-slate-500 flex-shrink-0 dark:text-cream-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Expand All @@ -102,7 +102,7 @@ const originStyles: Record<string, string> = {
return (
<a
href={`/minerals/${naturalCounterpart.id}`}
class="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors dark:hover:bg-coffee-raised2"
class="flex items-start gap-4 p-4 hover:bg-slate-50 transition-colors dark:hover:bg-coffee-raised2"
>
<div class="w-12 h-12 flex-shrink-0 bg-slate-50 rounded-lg p-1 overflow-hidden dark:border dark:border-coffee-border">
{hasSvg ? (
Expand All @@ -116,13 +116,13 @@ const originStyles: Record<string, string> = {
alt={naturalCounterpart.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'"
/>
)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-slate-900 truncate dark:text-cream-primary">{naturalCounterpart.name}</span>
<span class="font-medium text-slate-900 line-clamp-2 dark:text-cream-primary">{naturalCounterpart.name}</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300">
natural
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/minerals/CrystalStructureCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const forms = mineral.cdl ? parseForms(mineral.cdl) : [];
<label class="block text-sm font-medium text-slate-700 mb-2 dark:text-cream-secondary">
CDL Notation
</label>
<code class="block w-full px-4 py-3 bg-slate-900 text-sky-400 rounded-lg font-mono text-sm overflow-x-auto dark:border dark:border-coffee-border">
<code class="block w-full px-4 py-3 bg-slate-900 text-sky-400 rounded-lg font-mono text-sm overflow-x-auto whitespace-pre-wrap break-words dark:border dark:border-coffee-border">
{mineral.cdl}
</code>
</div>
Expand Down
Loading
Loading