diff --git a/README.md b/README.md
index 1bfee906..ee611b55 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Visit the [Quarto site](https://quarto.org/docs/guide/) for documentation on usi
The `tutorials/` directory contains interactive data analysis tutorials:
- **`isamples_explorer.qmd`** - Interactive search and exploration of 6.7M samples
-- **`zenodo_isamples_analysis.qmd`** - Deep-dive DuckDB-WASM analysis tutorial
+- **`explorer_guided_tour.qmd`** - Guided tour of the Interactive Explorer (replaced the migration-era Zenodo deep-dive, 2026-07)
- **`parquet_cesium_isamples_wide.qmd`** - Cesium-based 3D globe visualization
- **`narrow_vs_wide_performance.qmd`** - Technical schema comparison
diff --git a/SERIALIZATIONS.md b/SERIALIZATIONS.md
index fd2906d5..a5e0a74f 100644
--- a/SERIALIZATIONS.md
+++ b/SERIALIZATIONS.md
@@ -104,7 +104,7 @@ builder — a fresh build is NOT bit-for-bit identical to them (see
| File | Role | Size | Rows | Upstream | Consumers | Spec |
|---|---|---:|---:|---|---|---|
-| `isamples_202601_wide_h3.parquet` | Wide with `h3_res{4,6,8}` BIGINT columns pre-joined | 292 MB | 20.7 M | wide | Deep-Dive Analysis tutorial (H3 filtering without join) | QUERY_SPEC §2.4 |
+| `isamples_202601_wide_h3.parquet` | Wide with `h3_res{4,6,8}` BIGINT columns pre-joined | 292 MB | 20.7 M | wide | archival/analyst convenience (former Deep-Dive tutorial, replaced 2026-07 by the Guided Tour which uses summary files) | QUERY_SPEC §2.4 |
| `isamples_202601_h3_summary_res4.parquet` | Continental tier: `(h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count, resolution)` | 580 KB | 38 K | wide_h3 | Interactive Explorer globe (zoomed out), Python Explorer H3 tier mode | QUERY_SPEC §2.4 |
| `isamples_202601_h3_summary_res6.parquet` | Regional tier | 1.6 MB | 112 K | wide_h3 | Interactive Explorer globe (mid zoom) | QUERY_SPEC §2.4 |
| `isamples_202601_h3_summary_res8.parquet` | Neighborhood tier | 2.4 MB | 176 K | wide_h3 | Interactive Explorer globe (close zoom) | QUERY_SPEC §2.4 |
diff --git a/_quarto.yml b/_quarto.yml
index adfe6c96..694e6b5c 100644
--- a/_quarto.yml
+++ b/_quarto.yml
@@ -37,8 +37,8 @@ website:
menu:
- text: Overview
href: how-to-use.qmd
- - text: Deep-Dive Analysis
- href: tutorials/zenodo_isamples_analysis.qmd
+ - text: Guided Tour
+ href: tutorials/explorer_guided_tour.qmd
- text: About
menu:
- text: Objectives
@@ -84,8 +84,8 @@ website:
contents:
- href: how-to-use.qmd
text: Overview
- - text: "Deep-Dive Analysis"
- href: tutorials/zenodo_isamples_analysis.qmd
+ - text: "Guided Tour"
+ href: tutorials/explorer_guided_tour.qmd
- id: about
title: "About"
style: "docked"
diff --git a/explorer.qmd b/explorer.qmd
index 9050b799..7786de94 100644
--- a/explorer.qmd
+++ b/explorer.qmd
@@ -7391,5 +7391,5 @@ Pre-aggregated H3 hexagonal indices achieve near-instant globe rendering, with s
## See Also
-- [Deep-Dive Analysis](/tutorials/zenodo_isamples_analysis.html) — DuckDB-WASM SQL tutorial
+- [Guided Tour](/tutorials/explorer_guided_tour.html) — seven views of the Explorer, with the queries behind them
- [Tutorials](/tutorials/) — index of all interactive tutorials
diff --git a/how-to-use.qmd b/how-to-use.qmd
index fe67c0ea..1a0676ce 100644
--- a/how-to-use.qmd
+++ b/how-to-use.qmd
@@ -36,7 +36,7 @@ Works in Chrome, Firefox, Edge, Safari, and Brave. No plugins, no downloads, no
All code is visible and foldable on tutorial pages. Want to build your own analysis?
-- **[Deep-Dive Analysis](/tutorials/zenodo_isamples_analysis.html)** — statistical exploration with Observable Plot
+- **[Guided Tour](/tutorials/explorer_guided_tour.html)** — seven views of the Explorer and the queries behind them
- **[Tutorials index](/tutorials/)** — step-by-step guides from basic exploration to advanced analysis
- **[GitHub](https://github.com/isamplesorg/)** — all source code and data pipelines
- **[Zenodo](https://zenodo.org/communities/isamples)** — archived datasets for reproducible research
@@ -115,16 +115,15 @@ browsers use the parquet versions.
### Which tutorial uses which file {.unnumbered}
-| | Interactive Explorer | Search Explorer | Deep-Dive Analysis |
+| | Interactive Explorer | Search Explorer | Guided Tour |
|---|:-:|:-:|:-:|
| `wide.parquet` | | ● | |
-| `wide_h3.parquet` | | | ● |
| `facet_summaries.parquet` | ● | ● | ● |
| `facet_cross_filter.parquet` | | ● | |
| `sample_facets_v2.parquet` | ● | ● | |
-| `h3_summary_res4/6/8.parquet` | ● | | |
+| `h3_summary_res4/6/8.parquet` | ● | | ● (res4) |
| `samples_map_lite.parquet` | ● | | |
-| `vocab_labels.parquet` | ● | ● | |
+| `vocab_labels.parquet` | ● | ● | ● |
### Quick query recipes {.unnumbered}
diff --git a/tests/test_navbar_dropdowns.py b/tests/test_navbar_dropdowns.py
index e2a58ed9..6ed164b8 100644
--- a/tests/test_navbar_dropdowns.py
+++ b/tests/test_navbar_dropdowns.py
@@ -29,11 +29,11 @@ def test_how_to_use_dropdown_has_overview(self, page):
menu = _open_dropdown(page, "How to Use")
assert menu.get_by_text("Overview").count() > 0
- def test_how_to_use_dropdown_has_deep_dive(self, page):
- """And I see Deep-Dive Analysis."""
+ def test_how_to_use_dropdown_has_guided_tour(self, page):
+ """And I see Guided Tour."""
page.goto(SITE_URL, wait_until="domcontentloaded")
menu = _open_dropdown(page, "How to Use")
- assert menu.get_by_text("Deep-Dive Analysis").count() > 0
+ assert menu.get_by_text("Guided Tour").count() > 0
class TestAboutDropdown:
diff --git a/tests/test_navigation.py b/tests/test_navigation.py
index 5b1b088e..3ccf25e9 100644
--- a/tests/test_navigation.py
+++ b/tests/test_navigation.py
@@ -24,7 +24,7 @@ def test_about_sidebar_only_shows_own_items(self, page):
sidebar = page.locator(".sidebar-navigation")
assert sidebar.get_by_text("Objectives", exact=True).count() > 0
# Should NOT show items from other sections
- assert sidebar.get_by_text("Deep-Dive Analysis").count() == 0
+ assert sidebar.get_by_text("Guided Tour").count() == 0
assert sidebar.get_by_text("Vocabularies", exact=True).count() == 0
def test_architecture_sidebar_only_shows_own_items(self, page):
@@ -33,7 +33,7 @@ def test_architecture_sidebar_only_shows_own_items(self, page):
assert sidebar.get_by_text("Vocabularies", exact=True).count() > 0
# Should NOT show items from other sections
assert sidebar.get_by_text("Objectives", exact=True).count() == 0
- assert sidebar.get_by_text("Deep-Dive Analysis").count() == 0
+ assert sidebar.get_by_text("Guided Tour").count() == 0
def test_research_sidebar_only_shows_own_items(self, page):
page.goto(f"{SITE_URL}/pubs.html", wait_until="domcontentloaded")
@@ -41,7 +41,7 @@ def test_research_sidebar_only_shows_own_items(self, page):
assert sidebar.get_by_text("Publications & Conferences").count() > 0
# Should NOT show items from other sections
assert sidebar.get_by_text("Objectives", exact=True).count() == 0
- assert sidebar.get_by_text("Deep-Dive Analysis").count() == 0
+ assert sidebar.get_by_text("Guided Tour").count() == 0
def test_sidebar_does_not_show_old_information_architecture(self, page):
page.goto(f"{SITE_URL}/design/index.html", wait_until="domcontentloaded")
@@ -57,10 +57,10 @@ def test_how_to_use_has_overview(self, page):
sidebar = page.locator(".sidebar-navigation")
assert sidebar.get_by_text("Overview", exact=True).count() > 0
- def test_how_to_use_has_deep_dive(self, page):
+ def test_how_to_use_has_guided_tour(self, page):
page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded")
sidebar = page.locator(".sidebar-navigation")
- assert sidebar.get_by_text("Deep-Dive Analysis").count() > 0
+ assert sidebar.get_by_text("Guided Tour").count() > 0
def test_how_to_use_has_globe_viz(self, page):
page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded")
diff --git a/tests/test_tutorials_landing.py b/tests/test_tutorials_landing.py
index 8b0ba16e..25f50f93 100644
--- a/tests/test_tutorials_landing.py
+++ b/tests/test_tutorials_landing.py
@@ -15,10 +15,11 @@
class TestHowToUseLanding:
"""Scenario: Landing page lists all tutorial pathways."""
- def test_has_deep_dive_link(self, page):
- """Given I am on the How to Use page, Then I see a link to Deep-Dive Analysis."""
+ def test_has_guided_tour_link(self, page):
+ """Given I am on the How to Use page, Then I see a link to the Guided Tour
+ in the MAIN CONTENT (scoped so the permanent navbar link can't satisfy it)."""
page.goto(HOW_TO_USE_URL, wait_until="domcontentloaded")
- link = page.locator("a:has-text('Deep-Dive')")
+ link = page.locator("main a:has-text('Guided Tour')")
assert link.count() > 0
def test_has_globe_viz_link(self, page):
@@ -37,14 +38,18 @@ def test_has_narrow_vs_wide_link(self, page):
class TestTutorialPageLoads:
"""Scenario: Each tutorial page loads without JavaScript errors."""
- def test_deep_dive_loads(self, page):
- """Given I navigate to the Deep-Dive tutorial, Then no JS errors appear."""
+ def test_guided_tour_loads(self, page):
+ """Given I navigate to the Guided Tour, Then the LIVE CELLS actually render
+ (waits for an Observable Plot SVG — DuckDB query + chart — not just DOM)."""
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(
- f"{SITE_URL}/tutorials/zenodo_isamples_analysis.html",
+ f"{SITE_URL}/tutorials/explorer_guided_tour.html",
wait_until="domcontentloaded",
)
+ # The source-counts chart is the first Plot output; its appearance proves
+ # OJS booted, DuckDB-WASM initialized, and a remote parquet query ran.
+ page.wait_for_selector("figure svg, svg.plot-d6a7b5", timeout=90_000)
assert page.title() != ""
# Allow known non-critical errors but flag unexpected ones
critical = [e for e in errors if "TypeError" in e or "ReferenceError" in e]
diff --git a/tutorials/archive/parquet_cesium_isamples_wide.qmd b/tutorials/archive/parquet_cesium_isamples_wide.qmd
index 4380b9a9..197bc55a 100644
--- a/tutorials/archive/parquet_cesium_isamples_wide.qmd
+++ b/tutorials/archive/parquet_cesium_isamples_wide.qmd
@@ -1079,4 +1079,4 @@ html`${
- [Interactive Explorer](/tutorials/isamples_explorer.html) - Search and filter samples
- [Narrow vs Wide Performance](/tutorials/narrow_vs_wide_performance.html) - Benchmark comparison
-- [Deep-Dive Analysis](/tutorials/zenodo_isamples_analysis.html) - Comprehensive DuckDB-WASM tutorial
+- [Guided Tour](/tutorials/explorer_guided_tour.html) - Seven views of the Explorer, with live queries
diff --git a/tutorials/explorer_guided_tour.qmd b/tutorials/explorer_guided_tour.qmd
new file mode 100644
index 00000000..c12482df
--- /dev/null
+++ b/tutorials/explorer_guided_tour.qmd
@@ -0,0 +1,241 @@
+---
+title: "A Guided Tour: What 6.7 Million Samples Can Tell You"
+subtitle: "Seven views of the Interactive Explorer, and the questions each one answers"
+author: "iSamples Team"
+date: "2026-07-24"
+aliases:
+ - /tutorials/zenodo_isamples_analysis.html
+format:
+ html:
+ code-fold: true
+ code-summary: "Show the query behind this number"
+ toc: true
+ toc-depth: 2
+ theme: cosmo
+---
+
+The [Interactive Explorer](https://isamples.org/explorer.html) covers ~6.7
+million physical samples — rock cores, potsherds, tissue vouchers, museum
+specimens — about 6 million of them on one globe, running entirely in your
+browser with no server behind it. This
+tour walks through seven views of it, each answering a question about the data.
+Most stops are **live links** — click one and the Explorer opens at the position,
+search, and zoom being described (the URL carries that state; a couple of stops
+ask you to click one control yourself, where the URL doesn't reach).
+
+A few stops also include a small **"the number behind the view"** code cell that
+computes what you're seeing, live, from the same public data files the Explorer
+itself reads. Unfold the code to see how — and to take your first step from
+*viewing* the data to *querying* it.
+
+*(This page replaces an earlier "Zenodo deep-dive analysis" tutorial from the
+2025 migration era; the browser-analysis techniques it demonstrated are now the
+Explorer's own architecture. For how that works, see the pointers at the end.)*
+
+```{ojs}
+//| output: false
+// Boot DuckDB-WASM (the same engine the Explorer uses) + Observable Plot
+duckdb = import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/+esm")
+Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")
+```
+
+```{ojs}
+//| output: false
+db = {
+ const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
+ const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
+ const worker_url = URL.createObjectURL(
+ new Blob([`importScripts("${bundle.mainWorker}");`], {type: 'text/javascript'})
+ );
+ const worker = new Worker(worker_url);
+ const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
+ const db_instance = new duckdb.AsyncDuckDB(logger, worker);
+ await db_instance.instantiate(bundle.mainModule, bundle.pthreadWorker);
+ return db_instance;
+}
+
+conn = db.connect()
+
+sql = async (q) => {
+ const c = await conn;
+ const result = await c.query(q);
+ return result.toArray().map(r => {
+ const o = r.toJSON();
+ for (const k of Object.keys(o)) if (typeof o[k] === 'bigint') o[k] = Number(o[k]);
+ return o;
+ });
+}
+
+// The canonical data files — the SAME ones the Explorer loads (see CANONICAL.md).
+// All three used on this page are kilobyte-scale, so these cells load instantly.
+FACET_SUMMARIES = 'https://data.isamples.org/isamples_202608_facet_summaries.parquet'
+VOCAB_LABELS = 'https://data.isamples.org/vocab_labels_202608.parquet'
+H3_RES4 = 'https://data.isamples.org/isamples_202608_h3_summary_res4.parquet'
+```
+
+## Stop 1 — The world at a glance
+
+**[Open the Explorer →](https://isamples.org/explorer.html)**
+
+
+
+The first thing the globe tells you is *where science samples the world* — and
+where it doesn't. Blue (SESAR) dots trace the oceans: decades of marine cores
+and dredges. Red (OpenContext) concentrates in the Mediterranean and Near East:
+archaeological excavation. Green (GEOME) follows biodiversity fieldwork;
+yellow (Smithsonian) reflects museum collections. Sampling is not uniform — it
+is a map of scientific attention.
+
+```{ojs}
+sources = sql(`
+ SELECT facet_value AS source, count
+ FROM read_parquet('${FACET_SUMMARIES}')
+ WHERE facet_type = 'source'
+ ORDER BY count DESC`)
+```
+
+```{ojs}
+Plot.plot({
+ marginLeft: 110,
+ height: 180,
+ x: {label: "located samples", tickFormat: "s"},
+ y: {label: null},
+ marks: [
+ Plot.barX(sources, {y: "source", x: "count",
+ fill: d => ({SESAR: "#3366CC", OPENCONTEXT: "#DC3912",
+ GEOME: "#109618", SMITHSONIAN: "#FF9900"}[d.source] || "#666")}),
+ Plot.text(sources, {y: "source", x: "count", text: d => d.count.toLocaleString(), dx: 4, textAnchor: "start"})
+ ]
+})
+```
+
+One honest wrinkle the globe can't show: these bars total **~6.03 million** —
+the *located* samples. Roughly 700,000 more records exist in the collection
+with no usable coordinates: they never appear on the globe or in the viewport
+table, but a world-scope search still finds them. Counting only what's visible
+would overstate the map and understate the archive.
+
+## Stop 2 — Search that knows concepts
+
+**[Search "pottery Cyprus" →](https://isamples.org/explorer.html?search=pottery+Cyprus)**
+
+
+
+Type *pottery Cyprus* and 1,305 samples answer, with the top matches pinned on
+the island. Two things make this more than word-matching. First, the search
+index folds in **vocabulary concepts**: a sample tagged with the *concept*
+"pottery" matches even if its free text never contains the word. Second, it
+folds in **place names**, so "Cyprus" finds samples whose sampling-site place
+metadata says Cyprus even when their free-text descriptions never mention it. (Both were real user
+bug reports once — the fixes became the index's design.)
+
+Search runs on a pre-built sharded index: your query fetches a few small files,
+not the whole dataset. The classic full-scan search still exists — add
+`&fts=off` to the URL to feel the difference.
+
+## Stop 3 — Two sites, two sciences
+
+**[Fly to central Anatolia →](https://isamples.org/explorer.html#v=1&lat=37.6619&lng=32.8334&alt=150000&mode=point)**
+
+
+
+The densest region-scale cell in the whole collection is not over a volcano or
+a reef — it centers on the countryside around Çatalhöyük, a Neolithic settlement
+mound in Turkey excavated for decades (the cell aggregates ~150,000 samples from
+the surrounding region's digs). Compare
+**[Axial Seamount](https://isamples.org/explorer.html?search=axial+seamount+summit+caldera)**,
+an undersea volcano off Oregon: basalt collected by submersible. A potsherd
+from a dig and a basalt from the seafloor carry the same metadata shape —
+sample, sampling event, site, coordinates, material classification. That shared
+shape (the iSamples model) is what lets one tool serve both sciences.
+
+```{ojs}
+dense = sql(`
+ SELECT sample_count, center_lat, center_lng, dominant_source
+ FROM read_parquet('${H3_RES4}')
+ ORDER BY sample_count DESC
+ LIMIT 5`)
+```
+
+```{ojs}
+html`
The five densest cells on the globe (click to fly there):
`)}
+`
+```
+
+## Stop 4 — Slicing by material
+
+**[Open the Explorer and expand the *Material* facet →](https://isamples.org/explorer.html)**
+
+Check a box — say *Mineral* — and everything updates together: globe, counts,
+table. Check two boxes and the counts still hold (that trick, fast multi-filter
+counting over millions of rows with no server, is most of the Explorer's hidden
+machinery). The facet labels are human-readable because a small vocabulary
+file maps classification URIs to names:
+
+```{ojs}
+materials = sql(`
+ SELECT COALESCE(vl.pref_label, fs.facet_value) AS material, fs.count
+ FROM read_parquet('${FACET_SUMMARIES}') fs
+ LEFT JOIN read_parquet('${VOCAB_LABELS}') vl ON fs.facet_value = vl.uri AND vl.lang = 'en'
+ WHERE fs.facet_type = 'material'
+ ORDER BY fs.count DESC
+ LIMIT 10`)
+```
+
+```{ojs}
+Plot.plot({
+ marginLeft: 200,
+ height: 300,
+ x: {label: "located samples", tickFormat: "s"},
+ y: {label: null},
+ marks: [Plot.barX(materials, {y: "material", x: "count", fill: "#4a7", sort: {y: "-x"}})]
+})
+```
+
+Rock and mineral dominate (SESAR's size shows through), but biogenic and
+anthropogenic materials are each hundreds of thousands strong — this is
+genuinely a cross-domain collection, not a geology database with guests.
+
+## Stop 5 — Density vs identity
+
+**[Toggle the heatmap →](https://isamples.org/explorer.html)** *(checkbox in
+the right panel)*, and try the **2D map** (globe button in the map toolbar →
+2D).
+
+Clusters answer "how many, roughly where." The heatmap answers "where is
+sampling *concentrated*" — it makes the Mediterranean glow. Zoomed in, point
+mode answers "which samples, exactly." The honest rule of thumb: heatmaps for
+patterns, points for identity, and never trust a color's *intensity* as a count
+— click and read the number instead.
+
+## Stop 6 — From view to data-in-hand
+
+Any view's sample table (below the globe) shows PID, place, date, and a
+**Source URL** linking each sample back to its home collection's record.
+**Download CSV** exports up to 50,000 samples matching your current viewport and filters (broader views export the first 50,000). And because the URL
+carries the whole view state, **Copy Link to Current View** gives you a
+citation-grade pointer to *this exact slice* — paste it in a paper, a class
+assignment, or an issue report.
+
+## Stop 7 — Under the hood (and doing this yourself)
+
+There is no server. The Explorer is a static page querying public
+[Parquet](https://parquet.apache.org/) files over HTTP range requests with
+[DuckDB-WASM](https://duckdb.org/docs/api/wasm/overview) — the code cells on
+this page use the identical technique against the identical files. To go
+further:
+
+- **[EXPLORER_QUERIES.md](https://github.com/isamplesorg/isamplesorg.github.io/blob/main/EXPLORER_QUERIES.md)** — plain-English walkthrough of every query the Explorer runs, with copy-paste DuckDB snippets
+- **[CANONICAL.md](https://github.com/isamplesorg/isamplesorg.github.io/blob/main/CANONICAL.md)** — which data files are canonical (this page uses only those)
+- **[DATA_PROVENANCE.md](https://github.com/isamplesorg/isamplesorg.github.io/blob/main/DATA_PROVENANCE.md)** — how every derived file is built from the source export
+- The **[iSamples Zenodo community](https://zenodo.org/communities/isamples)** — the archived, citable snapshot of the data substrate
+
+
diff --git a/tutorials/images/tour/tour_anatolia.png b/tutorials/images/tour/tour_anatolia.png
new file mode 100644
index 00000000..739bc28e
Binary files /dev/null and b/tutorials/images/tour/tour_anatolia.png differ
diff --git a/tutorials/images/tour/tour_search_pins.png b/tutorials/images/tour/tour_search_pins.png
new file mode 100644
index 00000000..d6be703d
Binary files /dev/null and b/tutorials/images/tour/tour_search_pins.png differ
diff --git a/tutorials/images/tour/tour_world.png b/tutorials/images/tour/tour_world.png
new file mode 100644
index 00000000..af69e720
Binary files /dev/null and b/tutorials/images/tour/tour_world.png differ
diff --git a/tutorials/index.qmd b/tutorials/index.qmd
index 37975633..d3db435d 100644
--- a/tutorials/index.qmd
+++ b/tutorials/index.qmd
@@ -9,7 +9,7 @@ number-sections: false
| Tutorial | What You'll Learn |
|----------|-------------------|
| [**Interactive Explorer**](/explorer.html) | Browse, search, and filter 6.7M samples on a 3D globe or in a paginated table |
-| [**Deep-Dive Analysis**](zenodo_isamples_analysis.qmd) | Comprehensive DuckDB-WASM analysis with Observable JS — charts, maps, statistics |
+| [**Guided Tour**](explorer_guided_tour.qmd) | Seven views of the Interactive Explorer — what each answers, with live deep links and the queries behind the numbers |
| [**Technical: Narrow vs Wide**](narrow_vs_wide_performance.qmd) | Schema comparison and performance benchmarks for the PQG data formats |
## What's in the Data? {.unnumbered}
diff --git a/tutorials/narrow_vs_wide_performance.qmd b/tutorials/narrow_vs_wide_performance.qmd
index 166e110c..5452f84b 100644
--- a/tutorials/narrow_vs_wide_performance.qmd
+++ b/tutorials/narrow_vs_wide_performance.qmd
@@ -1070,4 +1070,4 @@ The wide format eliminates ~9M edge rows (60% file size reduction). The H3 varia
- [Interactive Explorer](/explorer.html) - Browse samples on a 3D globe
- [Search Explorer](isamples_explorer.qmd) - Faceted search and filter across all samples
-- [Deep-Dive Analysis](zenodo_isamples_analysis.qmd) - Comprehensive DuckDB-WASM tutorial
+- [Guided Tour](explorer_guided_tour.qmd) - Seven views of the Explorer, with live queries
diff --git a/tutorials/zenodo_isamples_analysis.qmd b/tutorials/zenodo_isamples_analysis.qmd
deleted file mode 100644
index d3a499b8..00000000
--- a/tutorials/zenodo_isamples_analysis.qmd
+++ /dev/null
@@ -1,1507 +0,0 @@
----
-title: "Efficient Analysis of Large iSamples Dataset from Zenodo"
-subtitle: "Using DuckDB-WASM and Observable JS for Browser-Based Data Analysis"
-author: "iSamples Team"
-date: "2025-09-05"
-format:
- html:
- code-fold: true
- code-summary: "Show code"
- toc: true
- toc-depth: 3
- theme: cosmo
- include-in-header:
- text: |
-
-
----
-
-# Introduction
-
-This tutorial demonstrates how to efficiently analyze large geospatial datasets directly in your browser without downloading entire files. We'll use DuckDB-WASM and Observable JS to perform fast, memory-efficient analysis and create interactive visualizations.
-
-**Note**: This tutorial attempts to connect to the live iSamples dataset (~300MB, 6+ million records). If CORS restrictions prevent access to the remote file, it automatically falls back to a representative demo dataset that demonstrates the same analytical techniques.
-
-## Key Technologies
-
-- **DuckDB-WASM**: In-browser analytical database with HTTP range request support
-- **Observable Plot**: Grammar of graphics for interactive visualizations
-- **Observable Inputs**: Interactive controls for data exploration
-- **CORS Handling**: Automatic fallback for cross-origin restrictions
-
-## Dataset Information
-
-**Primary dataset** (Jan 2026, H3-indexed):
-- **URL**: `https://data.isamples.org/isamples_202601_wide_h3.parquet`
-- **Size**: ~292 MB wide format with H3 indices, 6.7M MaterialSampleRecords (20M total rows)
-- **H3 columns**: Pre-computed `h3_res4`, `h3_res6`, `h3_res8` (BIGINT) for spatial grouping
-- **Sources**: SESAR (4.6M), OpenContext (1M), GEOME (605K), Smithsonian (322K)
-- **Hosting**: Cloudflare R2 with HTTP range request support
-
-**Facet summaries** (2KB, instant):
-- **URL**: `https://data.isamples.org/isamples_202601_facet_summaries.parquet`
-- **Schema**: `facet_type`, `facet_value`, `scheme`, `count`
-
-**Note**: *Data was originally archived on Zenodo and is now served from Cloudflare R2 for better performance and reliability.*
-
-**Fallback dataset** (if remote data fails):
-- **Type**: Generated demo data with realistic structure
-- **Size**: 10K records with same schema and representative geographic distribution
-- **Purpose**: Demonstrates all analytical techniques with faster loading
-
-# Setup and Database Connection
-
-```{ojs}
-//| label: test-reactivity
-
-// Simple test to debug Observable reactivity
-viewof test_input = Inputs.range([1, 100], {
- label: "Test Input:",
- step: 1,
- value: 42
-})
-
-// In Observable, the input itself IS the value when referenced reactively
-test_value = test_input
-
-test_output = test_value * 2
-
-md`
-## 🧪 Reactivity Test
-
-**Raw input object**: ${typeof test_input} (Constructor: ${test_input?.constructor?.name})
-**Extracted value**: ${test_value}
-**Output (2x input)**: ${test_output}
-**Value type**: ${typeof test_value}
-
-This should update in real-time as you move the slider.
-`
-```
-
-```{ojs}
-//| label: setup
-//| code-fold: true
-
-// Import required libraries using reliable CDNs
-duckdb = import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/+esm")
-Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")
-Inputs = import("https://cdn.jsdelivr.net/npm/@observablehq/inputs@0.10/+esm")
-d3 = require("d3@7")
-topojson = require("topojson-client@3")
-
-// Dataset URLs - try multiple options for CORS compatibility
-// Primary: Cloudflare R2 (Jan 2026 wide format with H3 indices)
-parquet_urls = [
- 'https://data.isamples.org/isamples_202601_wide_h3.parquet',
-
- // Fallback: original wide format without H3
- 'https://data.isamples.org/current/wide.parquet',
-
- // Fallback: older versions
- 'https://labs.dataunbound.com/docs/2025/07/isamples_export_2025_04_21_16_23_46_geo.parquet',
- 'https://zenodo.org/api/records/15278211/files/isamples_export_2025_04_21_16_23_46_geo.parquet/content'
-]
-
-// Pre-computed facet summaries (2KB - loads instantly)
-facet_summaries_url = 'https://data.isamples.org/isamples_202601_facet_summaries.parquet'
-
-// Test CORS and find working URL - with rate limiting protection
-working_parquet_url = {
- // Check if we've recently failed (to avoid repeated rate limiting)
- const lastFailTime = localStorage.getItem('zenodo_last_fail');
- const now = Date.now();
- if (lastFailTime && (now - parseInt(lastFailTime)) < 300000) { // 5 minutes
- console.log('⏳ Recently hit rate limit, using demo data');
- return null;
- }
-
- for (const url of parquet_urls) {
- try {
- console.log(`Testing URL: ${url}`);
- // Test with a small HEAD request first
- const response = await fetch(url, {
- method: 'HEAD',
- mode: 'cors'
- });
- if (response.ok) {
- console.log(`✅ Working URL found: ${url}`);
- // Clear any previous failure time
- localStorage.removeItem('zenodo_last_fail');
- return url;
- } else if (response.status === 429) {
- console.log(`⚠️ Rate limited: ${url}`);
- localStorage.setItem('zenodo_last_fail', now.toString());
- return null;
- }
- } catch (error) {
- console.log(`❌ Failed URL: ${url}, Error: ${error.message}`);
- if (error.message.includes('429') || error.message.includes('TOO MANY REQUESTS')) {
- localStorage.setItem('zenodo_last_fail', now.toString());
- return null;
- }
- continue;
- }
- }
-
- // If no URL works, we'll use a fallback approach
- console.log("⚠️ No direct URL worked, will use demo data");
- return null;
-}
-
-// Create DuckDB instance and connect to remote parquet
-db = {
- // Use DuckDB ES modules from jsdelivr
- const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
-
- // Select bundle for the platform
- const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
-
- // Create worker
- const worker_url = URL.createObjectURL(
- new Blob([`importScripts("${bundle.mainWorker}");`], {type: 'text/javascript'})
- );
-
- const worker = new Worker(worker_url);
- const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
- const db_instance = new duckdb.AsyncDuckDB(logger, worker);
-
- // Initialize the database
- await db_instance.instantiate(bundle.mainModule, bundle.pthreadWorker);
-
- // Connect to database
- const conn = await db_instance.connect();
-
- if (working_parquet_url) {
- try {
- // Create view with aliased column names to match downstream queries.
- // The Jan 2026 wide parquet uses different names than the original schema.
- await conn.query(`CREATE VIEW isamples_data AS SELECT *,
- latitude AS sample_location_latitude,
- longitude AS sample_location_longitude,
- n AS source_collection,
- pid AS sample_identifier,
- CAST(NULL AS VARCHAR) AS has_material_category
- FROM read_parquet('${working_parquet_url}')`);
-
- // Test the connection with a simple query to catch rate limiting
- await conn.query(`SELECT count(*) FROM isamples_data LIMIT 1`);
- console.log("✅ Successfully connected to remote Parquet file");
-
- } catch (error) {
- console.log("❌ Failed to read remote Parquet:", error.message);
- // Check if it's a rate limiting error
- if (error.message.includes('429') || error.message.includes('TOO MANY REQUESTS')) {
- console.log("⏳ Rate limited - storing failure time");
- localStorage.setItem('zenodo_last_fail', Date.now().toString());
- }
- // Create demo data as fallback
- await createDemoData(conn);
- }
- } else {
- // Create demo data as fallback
- await createDemoData(conn);
- }
-
- return conn;
-}
-
-// Function to create demo data when remote file is not accessible
-createDemoData = async (conn) => {
- console.log("Creating demo dataset...");
-
- // Create a demo table with similar structure and realistic data
- await conn.query(`
- CREATE TABLE isamples_data AS
- SELECT
- 'DEMO_' || i as sample_identifier,
- CASE
- WHEN i % 4 = 0 THEN 'SESAR'
- WHEN i % 4 = 1 THEN 'OPENCONTEXT'
- WHEN i % 4 = 2 THEN 'GEOME'
- ELSE 'SMITHSONIAN'
- END as source_collection,
-
- -- Generate realistic coordinates
- CASE
- WHEN i % 5 = 0 THEN -120 + (random() * 50) -- North America
- WHEN i % 5 = 1 THEN -10 + (random() * 40) -- Europe
- WHEN i % 5 = 2 THEN 100 + (random() * 40) -- Asia
- WHEN i % 5 = 3 THEN 115 + (random() * 30) -- Australia
- ELSE -180 + (random() * 360) -- Other
- END as sample_location_longitude,
-
- CASE
- WHEN i % 5 = 0 THEN 25 + (random() * 25) -- North America
- WHEN i % 5 = 1 THEN 35 + (random() * 35) -- Europe
- WHEN i % 5 = 2 THEN 20 + (random() * 30) -- Asia
- WHEN i % 5 = 3 THEN -40 + (random() * 30) -- Australia
- ELSE -90 + (random() * 180) -- Other
- END as sample_location_latitude,
-
- CASE
- WHEN i % 8 = 0 THEN 'rock'
- WHEN i % 8 = 1 THEN 'mineral'
- WHEN i % 8 = 2 THEN 'sediment'
- WHEN i % 8 = 3 THEN 'soil'
- WHEN i % 8 = 4 THEN 'fossil'
- WHEN i % 8 = 5 THEN 'meteorite'
- WHEN i % 8 = 6 THEN 'organic'
- ELSE 'other'
- END as has_material_category,
-
- 'Demo sample ' || i as label
-
- FROM generate_series(1, 10000) t(i) -- Generate 10,000 demo records
- `);
-
- console.log("✅ Demo dataset created with 10,000 sample records");
-}
-```
-
-```{ojs}
-//| label: connection-status
-
-md`
-## Connection Status
-
-${working_parquet_url ?
- `✅ **Connected to live data**: Using ${working_parquet_url.includes('r2.dev') ? 'Cloudflare R2' : working_parquet_url.includes('zenodo.org') ? 'Zenodo' : 'fallback'} hosting
-📊 **Dataset**: 6.7M MaterialSampleRecords from iSamples
-🌐 **Data source**: ${working_parquet_url}`
- :
- `⚠️ **Using demo data**: Remote file not accessible due to CORS restrictions
-📊 **Dataset**: 10K synthetic records with realistic structure
-💡 **Note**: This demonstrates the same analysis patterns with representative data`
-}
-`
-```
-
-# Basic Data Exploration
-
-Let's start with fundamental queries to understand the dataset structure and size.
-
-```{ojs}
-//| label: basic-stats
-
-// Get total record count - this only reads metadata!
-total_count = {
- const result = await db.query(`SELECT count(*) as total FROM isamples_data`);
- const rows = result.toArray();
- return Number(rows[0].total); // Convert BigInt to Number
-}
-
-// Count records with geographic coordinates
-geo_count = {
- const result = await db.query(`
- SELECT count(*) as geo_total
- FROM isamples_data
- WHERE sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- `);
- const rows = result.toArray();
- return Number(rows[0].geo_total); // Convert BigInt to Number
-}
-
-// Calculate percentage with coordinates
-geo_percentage = Math.round((geo_count / total_count) * 100)
-```
-
-```{ojs}
-//| label: display-basic-stats
-
-md`
-## Dataset Overview
-
-- **Total records**: ${total_count.toLocaleString()}
-- **Records with coordinates**: ${geo_count.toLocaleString()} (${geo_percentage}%)
-- **Data source**: ${working_parquet_url ? 'Remote Parquet file (~300 MB)' : 'Demo dataset (synthetic)'}
-- **Data transferred for these stats**: < 1 KB (metadata only!)
-`
-```
-
-# Source Collection Analysis
-
-Analyze the distribution of samples across different source collections.
-
-```{ojs}
-//| label: source-analysis
-
-// Get source collection counts
-source_data = {
- const result = await db.query(`
- SELECT
- source_collection,
- count(*) as sample_count,
- count(CASE WHEN sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- THEN 1 END) as geo_count
- FROM isamples_data
- GROUP BY source_collection
- ORDER BY sample_count DESC
- `);
- // Convert BigInt values to Numbers
- const processedData = result.toArray().map(row => ({
- ...row,
- sample_count: Number(row.sample_count),
- geo_count: Number(row.geo_count)
- }));
-
- return processedData;
-}
-
-// Create interactive table showing source distribution
-viewof source_table = Inputs.table(source_data, {
- columns: [
- "source_collection",
- "sample_count",
- "geo_count"
- ],
- header: {
- source_collection: "Source Collection",
- sample_count: "Total Samples",
- geo_count: "Samples with Coordinates"
- },
- format: {
- sample_count: d3.format(","),
- geo_count: d3.format(",")
- }
-})
-```
-
-```{ojs}
-//| label: source-chart
-
-// Create bar chart of source collections
-source_chart = {
- // Validate that source_data is an array
- if (!Array.isArray(source_data)) {
- return html`
Error: Source data is not available
`;
- }
-
- return Plot.plot({
- title: "Sample Distribution by Source Collection",
- x: {
- label: "Number of samples",
- tickFormat: "~s"
- },
- y: {
- label: "Source Collection",
- domain: source_data.map(d => d.source_collection)
- },
- marks: [
- Plot.barX(source_data, {
- x: "sample_count",
- y: "source_collection",
- fill: "steelblue",
- sort: {y: "x", reverse: true}
- }),
- Plot.text(source_data, {
- x: "sample_count",
- y: "source_collection",
- text: d => d3.format("~s")(d.sample_count),
- dx: 10,
- textAnchor: "start"
- })
- ],
- marginLeft: 120,
- height: 250,
- width: 600
- });
-}
-```
-
-# Geographic Distribution Analysis
-
-Examine the geographic spread of samples and identify regional patterns.
-
-```{ojs}
-//| label: geographic-stats
-
-// Get geographic statistics
-geo_stats = {
- const result = await db.query(`
- SELECT
- count(*) as total_with_coords,
- min(sample_location_latitude) as min_lat,
- max(sample_location_latitude) as max_lat,
- avg(sample_location_latitude) as avg_lat,
- min(sample_location_longitude) as min_lon,
- max(sample_location_longitude) as max_lon,
- avg(sample_location_longitude) as avg_lon
- FROM isamples_data
- WHERE sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- `);
- const rows = result.toArray();
- const row = rows[0];
- // Convert BigInt values to Numbers where needed
- return {
- ...row,
- total_with_coords: Number(row.total_with_coords)
- };
-}
-
-// Data-driven regional analysis using H3 res4 cell grouping
-// Replaces hardcoded CASE WHEN bounding boxes with dynamic discovery
-regional_data = {
- // Check if h3_res4 column exists (H3-indexed file)
- let hasH3 = false;
- try {
- const colCheck = await db.query(`SELECT h3_res4 FROM isamples_data LIMIT 1`);
- hasH3 = true;
- } catch (e) {
- hasH3 = false;
- }
-
- if (hasH3) {
- // H3-based regional grouping: discover dense clusters dynamically
- const result = await db.query(`
- SELECT
- h3_res4,
- COUNT(*) as sample_count,
- AVG(sample_location_latitude) as avg_lat,
- AVG(sample_location_longitude) as avg_lon,
- COUNT(DISTINCT source_collection) as source_count,
- MODE(source_collection) as dominant_source
- FROM isamples_data
- WHERE sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- AND h3_res4 IS NOT NULL
- GROUP BY h3_res4
- HAVING COUNT(*) > 100
- ORDER BY sample_count DESC
- `);
- // Assign region labels based on centroid location
- return result.toArray().map(row => {
- const lat = row.avg_lat;
- const lon = row.avg_lon;
- let region;
- if (lon >= -130 && lon <= -60 && lat >= 20 && lat <= 55) region = 'North America';
- else if (lon >= -15 && lon <= 45 && lat >= 30 && lat <= 75) region = 'Europe';
- else if (lon >= 90 && lon <= 150 && lat >= 15 && lat <= 55) region = 'East Asia';
- else if (lon >= 110 && lon <= 160 && lat >= -50 && lat <= -5) region = 'Australia';
- else if (lon >= -90 && lon <= -30 && lat >= -60 && lat <= 15) region = 'South America';
- else if (lon >= -20 && lon <= 55 && lat >= -40 && lat <= 30) region = 'Africa';
- else region = 'Other';
- return {
- region,
- source_collection: row.dominant_source,
- sample_count: Number(row.sample_count),
- avg_lat: row.avg_lat,
- avg_lon: row.avg_lon,
- h3_cell: row.h3_res4,
- source_count: Number(row.source_count)
- };
- });
- } else {
- // Fallback for non-H3 files: use simple lat/lon-based grouping
- const result = await db.query(`
- SELECT
- source_collection,
- count(*) as sample_count,
- avg(sample_location_latitude) as avg_lat,
- avg(sample_location_longitude) as avg_lon
- FROM isamples_data
- WHERE sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- GROUP BY source_collection
- ORDER BY sample_count DESC
- `);
- return result.toArray().map(row => {
- const lat = row.avg_lat;
- const lon = row.avg_lon;
- let region = 'Other';
- if (lon >= -130 && lon <= -60 && lat >= 20 && lat <= 55) region = 'North America';
- else if (lon >= -15 && lon <= 45 && lat >= 30 && lat <= 75) region = 'Europe';
- else if (lon >= 90 && lon <= 150 && lat >= 15 && lat <= 55) region = 'East Asia';
- else if (lon >= 110 && lon <= 160 && lat >= -50 && lat <= -5) region = 'Australia';
- return {
- region,
- source_collection: row.source_collection,
- sample_count: Number(row.sample_count),
- avg_lat: row.avg_lat,
- avg_lon: row.avg_lon
- };
- });
- }
-}
-```
-
-```{ojs}
-//| label: display-geo-stats
-
-md`
-## Geographic Statistics
-
-- **Latitude range**: ${geo_stats.min_lat.toFixed(3)}° to ${geo_stats.max_lat.toFixed(3)}°
-- **Longitude range**: ${geo_stats.min_lon.toFixed(3)}° to ${geo_stats.max_lon.toFixed(3)}°
-- **Average location**: ${geo_stats.avg_lat.toFixed(3)}°, ${geo_stats.avg_lon.toFixed(3)}°
-- **Dense H3 clusters**: ${regional_data.length} (cells with >100 samples)
-- **Regions discovered**: ${[...new Set(regional_data.map(d => d.region))].join(', ')}
-
-*Regional grouping is data-driven using H3 resolution-4 hexagonal cells, replacing hardcoded bounding boxes.*
-`
-```
-
-# Interactive Regional Explorer
-
-Create an interactive visualization to explore samples by region and source.
-
-```{ojs}
-//| label: regional-controls
-
-// Interactive region selector
-viewof selected_region = Inputs.select(
- ["All", ...new Set(regional_data?.map?.(d => d.region) || [])],
- {
- label: "Select Region:",
- value: "All"
- }
-)
-```
-
-```{ojs}
-//| label: regional-chart
-
-// Regional distribution chart (data-driven from H3 clusters)
-regional_chart = {
- if (!Array.isArray(regional_data)) {
- return html`
Error: Regional data is not available
`;
- }
-
- // Aggregate H3 cell data by discovered region
- const regionTotals = d3.rollup(
- regional_data,
- v => d3.sum(v, d => d.sample_count),
- d => d.region
- );
-
- const aggregatedData = Array.from(regionTotals, ([region, total]) => ({
- region: region,
- sample_count: total
- })).sort((a, b) => b.sample_count - a.sample_count);
-
- return Plot.plot({
- title: `Sample Distribution by Region (H3-derived, ${aggregatedData.length} regions)`,
- width: 700,
- height: 300,
- marginLeft: 120,
- x: {
- label: "Number of samples",
- tickFormat: "~s"
- },
- y: {
- label: "Region",
- domain: aggregatedData.map(d => d.region)
- },
- marks: [
- Plot.barX(aggregatedData, {
- x: "sample_count",
- y: "region",
- fill: "steelblue",
- sort: {y: "x", reverse: true}
- }),
- Plot.text(aggregatedData, {
- x: "sample_count",
- y: "region",
- text: d => d3.format("~s")(d.sample_count),
- dx: 10,
- textAnchor: "start"
- })
- ]
- });
-}
-```
-
-# Efficient Sampling for Visualization
-
-Create a representative sample of the data for detailed visualization while minimizing data transfer.
-
-```{ojs}
-//| label: sampling-controls
-
-// Sample size control
-viewof sample_size = Inputs.range([1000, 50000], {
- label: "Sample Size:",
- step: 1000,
- value: 10000
-})
-
-// Sample per collection limit
-viewof max_per_collection = Inputs.range([500, 5000], {
- label: "Max per Collection:",
- step: 250,
- value: 2500
-})
-```
-
-```{ojs}
-//| label: create-sample
-
-// Create stratified sample - simplified for DuckDB-WASM compatibility
-sample_data = {
- // In Observable, inputs are automatically reactive values
- const maxPerCollection = max_per_collection;
- const sampleSizeValue = sample_size;
-
- // Use a fixed sampling rate to avoid NaN issues
- const samplingRate = 0.1; // Sample 10% of data
-
- // Validate that all values are proper numbers
- if (isNaN(maxPerCollection) || isNaN(sampleSizeValue) || isNaN(samplingRate)) {
- console.error('Invalid numeric values detected', {maxPerCollection, sampleSizeValue, samplingRate});
- throw new Error('Invalid sampling parameters');
- }
-
- const result = await db.query(`
- WITH sampled_data AS (
- SELECT
- sample_identifier,
- source_collection,
- sample_location_longitude as longitude,
- sample_location_latitude as latitude,
- has_material_category,
- label
- FROM isamples_data
- WHERE sample_location_latitude IS NOT NULL
- AND sample_location_longitude IS NOT NULL
- AND random() < ${samplingRate}
- )
- SELECT * FROM sampled_data
- LIMIT ${sampleSizeValue}
- `);
- return result.toArray();
-}
-
-// Calculate sample statistics
-sample_stats = {
- const total = sample_data.length;
- const by_source = d3.rollup(sample_data, v => v.length, d => d.source_collection);
- return {
- total,
- by_source: Array.from(by_source, ([source, count]) => ({source, count}))
- .sort((a, b) => b.count - a.count)
- };
-}
-```
-
-```{ojs}
-//| label: display-sample-stats
-
-md`
-## Sample Statistics
-
-**Total sample size**: ${sample_stats.total.toLocaleString()} points
-**Data transfer**: ~${Math.round(sample_stats.total * 6 * 8 / 1024 / 1024)} MB (estimated)
-**Reduction factor**: ${Math.round(geo_count / sample_stats.total)}x fewer points
-
-**Distribution by source**:
-${sample_stats.by_source.map(d => `- ${d.source}: ${d.count.toLocaleString()}`).join('\n')}
-`
-```
-
-# Interactive World Map
-
-Create an interactive scatter plot map showing the geographic distribution of samples.
-
-```{ojs}
-//| label: map-controls
-
-// Map projection selector
-viewof projection = Inputs.select([
- "equirectangular",
- "orthographic",
- "mercator"
-], {
- label: "Map Projection:",
- value: "equirectangular"
-})
-
-// Point size control
-viewof point_size = Inputs.range([0.5, 5], {
- label: "Point Size:",
- step: 0.1,
- value: 1.5
-})
-```
-
-```{ojs}
-//| label: world-map
-
-// Load world map data
-world = fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json")
- .then(response => response.json())
-
-// Create world map with sample points
-world_map = {
- // Await the world data
- const worldData = await world;
- const countries = topojson.feature(worldData, worldData.objects.countries);
-
- // In Observable, inputs are automatically reactive values
- let pointRadius = point_size;
- const mapProjection = projection;
-
- // Validate that pointRadius is a proper number
- if (isNaN(pointRadius) || pointRadius <= 0) {
- console.error('Invalid point size detected:', point_size, 'using fallback:', 1.5);
- pointRadius = 1.5;
- }
-
- return Plot.plot({
- title: `Geographic Distribution of ${sample_stats.total.toLocaleString()} Sample Points`,
- projection: mapProjection,
- marks: [
- // World map outline
- Plot.geo(countries, {
- fill: "#f0f0f0",
- stroke: "#ccc",
- strokeWidth: 0.5
- }),
- // Sample points colored by source
- Plot.dot(sample_data, {
- x: "longitude",
- y: "latitude",
- fill: "source_collection",
- r: pointRadius,
- fillOpacity: 0.7,
- stroke: "white",
- strokeWidth: 0.2
- })
- ],
- color: {
- legend: true,
- domain: ["SESAR", "OPENCONTEXT", "GEOME", "SMITHSONIAN"],
- range: ["#3366cc", "#dc3912", "#109618", "#ff9900"]
- },
- width: 900,
- height: 500,
- style: {
- background: "#f8f9fa"
- }
- });
-}
-```
-
-# Material Category Analysis
-
-Explore the distribution of material categories across different sources.
-
-```{ojs}
-//| label: material-analysis
-
-// Material data: use pre-computed facet summaries for instant results
-// Falls back to full-scan if summaries unavailable
-material_data = {
- try {
- // Try pre-computed summaries first (2KB, instant)
- const result = await db.query(`
- SELECT
- facet_value as has_material_category,
- 'ALL' as source_collection,
- count as category_count
- FROM read_parquet('${facet_summaries_url}')
- WHERE facet_type = 'material'
- ORDER BY count DESC
- `);
- return result.toArray().map(row => ({
- ...row,
- category_count: Number(row.category_count)
- }));
- } catch (e) {
- console.warn("Facet summaries unavailable, falling back to full scan:", e.message);
- const result = await db.query(`
- SELECT
- source_collection,
- has_material_category,
- count(*) as category_count
- FROM isamples_data
- WHERE has_material_category IS NOT NULL
- GROUP BY source_collection, has_material_category
- ORDER BY source_collection, category_count DESC
- `);
- return result.toArray().map(row => ({
- ...row,
- category_count: Number(row.category_count)
- }));
- }
-}
-
-// Top categories from pre-computed summaries (instant)
-top_categories = {
- try {
- const result = await db.query(`
- SELECT
- facet_value as has_material_category,
- count as total_count
- FROM read_parquet('${facet_summaries_url}')
- WHERE facet_type = 'material'
- ORDER BY count DESC
- LIMIT 10
- `);
- return result.toArray().map(row => ({
- ...row,
- total_count: Number(row.total_count)
- }));
- } catch (e) {
- console.warn("Facet summaries unavailable, falling back to full scan:", e.message);
- const result = await db.query(`
- SELECT
- has_material_category,
- count(*) as total_count
- FROM isamples_data
- WHERE has_material_category IS NOT NULL
- GROUP BY has_material_category
- ORDER BY total_count DESC
- LIMIT 10
- `);
- return result.toArray().map(row => ({
- ...row,
- total_count: Number(row.total_count)
- }));
- }
-}
-```
-
-```{ojs}
-//| label: material-controls
-
-// Category selector for detailed view
-viewof selected_category = Inputs.select(
- ["All", ...top_categories.map(d => d.has_material_category)],
- {
- label: "Focus on Category:",
- value: "All"
- }
-)
-
-// Filter material data
-filtered_material_data = selected_category === "All"
- ? material_data
- : material_data.filter(d => d.has_material_category === selected_category)
-```
-
-```{ojs}
-//| label: material-charts
-
-// Top categories chart
-categories_chart = Plot.plot({
- title: "Top 10 Material Categories",
- x: {
- label: "Number of samples",
- tickFormat: "~s"
- },
- y: {
- label: "Material Category",
- domain: top_categories.map(d => d.has_material_category)
- },
- marks: [
- Plot.barX(top_categories, {
- x: "total_count",
- y: "has_material_category",
- fill: "coral",
- sort: {y: "x", reverse: true}
- }),
- Plot.text(top_categories, {
- x: "total_count",
- y: "has_material_category",
- text: d => d3.format("~s")(d.total_count),
- dx: 10,
- textAnchor: "start"
- })
- ],
- marginLeft: 150,
- height: 300,
- width: 700
-})
-
-// Material by source chart
-material_by_source_chart = {
- // Validate that data is available
- if (!Array.isArray(material_data) || !Array.isArray(top_categories)) {
- return html`
Error: Material data is not available
`;
- }
-
- // In Observable, inputs are automatically reactive values
- const currentCategory = selected_category;
-
- // Filter the data based on selection
- let chartData;
- if (currentCategory === "All") {
- // For "All", show top 10 categories across all sources
- chartData = material_data.filter(d =>
- top_categories.map(c => c.has_material_category).includes(d.has_material_category)
- );
- } else {
- // For specific category, show by source collection
- chartData = material_data.filter(d => d.has_material_category === currentCategory);
- }
-
- // If no data, return early with a message
- if (chartData.length === 0) {
- return html`
No data available for selected category: ${currentCategory}
`;
- }
-
- return Plot.plot({
- title: currentCategory === "All"
- ? "Material Categories by Source Collection"
- : `${currentCategory} by Source Collection`,
- x: {
- label: "Number of samples",
- tickFormat: "~s"
- },
- y: {
- label: currentCategory === "All" ? "Material Category" : "Source Collection"
- },
- color: {
- legend: true,
- domain: ["SESAR", "OPENCONTEXT", "GEOME", "SMITHSONIAN"],
- range: ["#3366cc", "#dc3912", "#109618", "#ff9900"]
- },
- marks: [
- Plot.barX(chartData, {
- x: "category_count",
- y: currentCategory === "All" ? "has_material_category" : "source_collection",
- fill: "source_collection",
- sort: {y: "x", reverse: true}
- })
- ],
- marginLeft: 150,
- height: Math.max(250, chartData.length * 20),
- width: 700
- });
-}
-```
-
-# Performance Summary
-
-This browser-based approach demonstrates remarkable efficiency compared to traditional methods.
-
-```{ojs}
-//| label: performance-summary
-
-md`
-# Performance Analysis
-
-## Browser-Based vs Traditional Approaches
-
-| Approach | Time | Memory | Data Transfer | Environment |
-|----------|------|--------|---------------|-------------|
-| **Traditional (pandas)** | 40-150s | 600-1200 MB | 300 MB | Local Python |
-| **Our browser approach** | 10-30s | <100 MB | <5 KB + samples | Any browser |
-| **Improvement** | **~5x faster** | **~10x less memory** | **~99% less transfer** | **Universal** |
-
-## Key Benefits
-
-✅ **Universal Access**: Runs in any modern browser
-✅ **Memory Efficient**: Analyze 300MB datasets using <100MB browser memory
-✅ **Fast**: Instant metadata queries, efficient sampling
-✅ **Interactive**: Real-time parameter adjustment and visualization
-✅ **Scalable**: Same approach works for GB or TB datasets
-✅ **Reproducible**: Self-contained analysis with no local setup required
-
-## Technical Achievements
-
-- **HTTP Range Requests**: Only downloads needed data portions
-- **Columnar Processing**: Parquet format enables efficient column-wise operations
-- **Lazy Evaluation**: Queries are optimized before execution
-- **In-Browser Analytics**: Full analytical database running in JavaScript
-- **Interactive Visualization**: Real-time exploration with Observable Plot
-
-This approach enables **big data analysis in any browser** and makes large-scale geospatial analysis universally accessible! 🌍
-`
-```
-
-# Additional Resources
-
-```{ojs}
-//| label: resources
-
-md`
-## 📚 Learn More
-
-- [DuckDB-WASM Documentation](https://duckdb.org/docs/api/wasm/)
-- [Observable Plot](https://observablehq.com/plot/)
-- [Observable Inputs](https://observablehq.com/@observablehq/inputs)
-- [GeoParquet Specification](https://geoparquet.org/)
-- [HTTP Range Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
-- [iSamples Project](https://www.isamples.org/)
-
-## 🔧 Technical Implementation
-
-This notebook demonstrates how to:
-1. Connect to remote Parquet files using DuckDB-WASM
-2. Perform efficient metadata-only queries
-3. Create stratified samples for visualization
-4. Build interactive controls and visualizations
-5. Achieve high performance with minimal data transfer
-
-The complete workflow enables sophisticated data analysis directly in the browser without any local software installation or large file downloads.
-`
-```
-
-# 🔍 Viewport Map with Graceful Degradation (deck.gl ↔ DuckDB-WASM)
-
-```{ojs}
-//| label: map-libs
-// Import CSS first
-{
- const link = document.createElement("link");
- link.rel = "stylesheet";
- link.href = "https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.css";
- document.head.appendChild(link);
-}
-
-// Map libs only - skip deck.gl for now due to dependency issues
-maplibregl = await import("https://cdn.skypack.dev/maplibre-gl@3?min")
-
-// For now, we'll skip deck.gl due to luma.gl dependency issues
-// and just show the map without WebGL overlays
-decklib = null
-```
-
-```{ojs}
-//| label: map-config
-MAPCFG = ({
- center: [20.0, 45.0], // Centered to include Italy and Israel
- zoom: 3, // Zoomed out one level
- minZoom: 1,
- maxZoom: 17,
- basemap: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
- pointColor: [20,110,180,200],
- pointRadiusPx: 2,
- maxPerTileHard: sample_limit_value, // Dynamic sample limit from controls
- modulusByZoom(z){
- if (z >= 13) return 1;
- if (z >= 11) return 2;
- if (z >= 9 ) return 8;
- if (z >= 7 ) return 32;
- if (z >= 5 ) return 128;
- return 512;
- },
- aggDegByZoom(z){
- if (z >= 11) return 0.05;
- if (z >= 9 ) return 0.1;
- if (z >= 7 ) return 0.25;
- if (z >= 5 ) return 0.5;
- return 1.0;
- }
-})
-```
-
-```{ojs}
-//| label: viewport-sampling-controls
-
-// Text input for viewport sample limit
-viewof sample_limit = Inputs.text({
- label: "Viewport Sample Limit:",
- value: "20000",
- placeholder: "Enter number (works reliably up to ~1M)"
-})
-
-// Convert to number with validation
-sample_limit_value = Math.max(1, parseInt(sample_limit) || 20000)
-
-// Display current sample limit
-md`**Current viewport sample limit**: ${sample_limit_value.toLocaleString()} samples per viewport
-
-*Note: Performance tested up to ~1M samples. For larger datasets, need more scalable approach (e.g., lonboard/deck.gl optimization)*`
-```
-
-```{ojs}
-//| label: map-capability
-pickMode = () => {
- const c = document.createElement("canvas");
- const gl2 = c.getContext("webgl2", {antialias:false});
- if (gl2) return "full";
- const gl = c.getContext("webgl", {antialias:false});
- if (!gl) return "raster";
- const ext = gl.getExtension("WEBGL_debug_renderer_info");
- const r = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : "";
- const low = /swiftshader|llvmpipe|software/i.test(r) || gl.getParameter(gl.MAX_TEXTURE_SIZE) < 4096;
- return low ? "raster" : "lite";
-}
-MODE = pickMode()
-```
-
-```{ojs}
-//| label: map-container
-html`
-