Observable Notebook Kit 2.0
Data Desk notebooks use Observable Notebook Kit 2.0 - standalone HTML pages with embedded JavaScript that compile to static sites.
Cell types
<!doctype html>
<notebook theme="midnight">
<title>Research title</title>
<!-- Markdown -->
<script id="header" type="text/markdown">
# Research title
</script>
<!-- JavaScript -->
<script id="analysis" type="module">
const data = await FileAttachment("../data/flows.csv").csv({typed: true});
display(Inputs.table(data));
</script>
<!-- SQL (queries DuckDB) -->
<script id="flows" output="flows" type="application/sql" database="../data/data.duckdb" hidden>
SELECT * FROM flows ORDER BY date DESC
</script>
<!-- Raw HTML -->
<script id="chart" type="text/html">
<div id="map" style="height: 500px;"></div>
</script>
</notebook>
Key points:
- •Each
<script>has uniqueid - •Cells are
type="module"by default (ES6 syntax) - •Use
display()to render output (don't rely on return values) - •Variables defined in one cell available to all others
- •Use sentence case for all titles, headings, and chart titles (e.g., "Outages by country" not "Outages By Country")
- •Keep the "last updated" date element - this is auto-populated from git and should not be removed
Loading data
FileAttachment API
Paths relative to notebook (docs/index.html):
- •Data files in root
data/→ use../data/ - •Assets in
docs/assets/→ useassets/ - •Always
awaitFileAttachment calls
// CSV with type inference
const flows = await FileAttachment("../data/flows.csv").csv({typed: true});
// JSON
const projects = await FileAttachment("../data/projects.json").json();
// Parquet
const tracks = await FileAttachment("../data/tracks.parquet").parquet();
// Images
const img = await FileAttachment("assets/photo.jpg").url();
DuckDB / SQL cells (preferred for data)
Always prefer SQL cells with a DuckDB database over loading CSV/JSON directly. SQL cells query at build time, embedding results in HTML.
<script id="query" output="flows" type="application/sql" database="../data/data.duckdb" hidden> SELECT * FROM flows WHERE year >= 2020 </script>
Attributes:
- •
type="application/sql"- marks as SQL query - •
database="../data/data.duckdb"- path to database (relative to notebook) - •
output="flows"- variable name for results - •
hidden- don't display output (optional)
Results available as JS variable:
display(html`<p>Found ${flows.length} flows</p>`);
DuckDB client (for complex/dynamic queries)
const db = DuckDBClient.of(); const summary = await db.query(` SELECT year, count(*) as n, sum(volume_kt) as total FROM flows GROUP BY year ORDER BY year `); display(Inputs.table(summary));
Visualization with Observable Plot
display(Plot.plot({
title: "Annual volumes by destination",
x: {label: "Year"},
y: {label: "Volume (Mt)", grid: true},
color: {legend: true},
marks: [
Plot.barY(data, {x: "year", y: "volume", fill: "region", tip: true}),
Plot.ruleY([0])
]
}));
Common marks: Plot.line(), Plot.barY(), Plot.areaY(), Plot.dot()
Built-in: automatic scales, tooltips with tip: true, responsive layout
Interactive inputs
// Toggle
const show_all = view(Inputs.toggle({label: "Show all columns"}));
// Search
const searched = view(Inputs.search(data));
// Table
display(Inputs.table(searched, {
rows: 25,
columns: show_all ? undefined : ["name", "date", "value"]
}));
// Slider, select, etc.
const threshold = view(Inputs.range([0, 100], {step: 1, value: 50}));
const country = view(Inputs.select(["UK", "Norway", "Sweden"]));
view() makes input reactive - other cells auto-update when value changes.
External libraries
Load via dynamic imports or CDN:
<!-- CSS -->
<script type="text/html">
<link href="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css" rel="stylesheet" />
</script>
<!-- JS library -->
<script type="module">
const script = document.createElement('script');
script.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js';
script.onload = () => initMap();
document.head.appendChild(script);
</script>
Common patterns
Data aggregation
// Group by and sum
const annual = d3.rollup(flows, v => d3.sum(v, d => d.volume), d => d.year);
// Map to array
const data = Array.from(annual, ([year, volume]) => ({year, volume}))
.sort((a, b) => a.year - b.year);
Formatting
const formatDate = d3.utcFormat("%B %Y");
const formatNumber = d3.format(",.1f");
const formatCurrency = d3.format("$,.0f");
Inline calculations in markdown
// Calculate stats const total = d3.sum(flows, d => d.volume); const maxYear = d3.max(flows, d => d.year);
Reference in markdown:
<script type="text/markdown">
Analysis found ${total.toFixed(1)} Mt across ${flows.length} voyages,
peaking in ${maxYear}.
</script>
Geospatial (DuckDB Spatial)
<script type="application/sql" database="../data/flows.duckdb" output="ports"> SELECT port_name, ST_AsGeoJSON(geometry) as geojson, count(*) as visits FROM port_visits GROUP BY port_name, geometry </script>
Use in Mapbox/Leaflet:
ports.forEach(p => {
const coords = JSON.parse(p.geojson).coordinates;
new mapboxgl.Marker().setLngLat(coords).addTo(map);
});
Building and previewing
Before building or previewing, always run yarn first to install dependencies (including DuckDB). Without this, SQL queries will fail silently.
# Install dependencies (required for DuckDB queries) yarn # Preview with hot reload (auto-kills existing servers first) make preview # Build for production make build # Kill any orphaned preview servers make kill
For Claude Code: Run preview in background, then kill when done:
make preview & # Start in background # ... do work ... make kill # Clean up when finished
Resources
- •Observable Notebook Kit: https://observablehq.com/notebook-kit/
- •Observable Plot: https://observablehq.com/plot/
- •Observable Inputs: https://observablehq.com/notebook-kit/inputs
- •DuckDB SQL: https://duckdb.org/docs/sql/introduction