Build terminal-native data apps with fetch, components, and policy.
Fetch JSON with standard fetch(), transform it, and pass it into these components.
Network access is limited by the app's declared policy.
data-table – sortable rows with selectiondata-bars – horizontal, vertical, grouped, sparkline, LEDdata-boxplot – quartiles, whiskers, outliersdata-heatmap – 2D color grid with contour linesdata-tree – hierarchical data with expand/collapseFetch JSON, map to rows, call setValue. Sorting, scrolling, and selection are built in.
┌─────────────────────────────────────────────────────────────────────────────┐ │ Mag Location Depth km Time (UTC) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 5.1 92 km SSW of Suva, Fiji 580.3 2025-03-09 14:12:05 │ │ 4.7 128 km NNE of Hihifo, Tonga 210.0 2025-03-09 12:06:40 │ │ 4.2 18 km SSE of Ridgecrest, California 10.2 2025-03-09 13:50:00 │ │ 3.8 6 km NNE of Pahala, Hawaii 33.7 2025-03-09 13:31:40 │ │ 3.1 45 km S of Kaktovik, Alaska 0.0 2025-03-09 11:33:20 │ │ 2.7 14 km E of Guanica, Puerto Rico 8.0 2025-03-09 13:13:20 │ │ 2.4 9 km ENE of Magna, Utah 11.5 2025-03-09 11:16:40 │ │ 2.1 35 km NW of Anchor Point, Alaska 62.4 2025-03-09 12:56:40 │ │ 1.9 8 km WSW of Cobb, California 2.1 2025-03-09 12:40:00 │ │ 1.8 12 km W of Morton, Washington 18.3 2025-03-09 11:00:00 │ │ 1.5 22 km SE of Stanley, Idaho 5.0 2025-03-09 12:23:20 │ │ 1.2 5 km N of The Geysers, California 1.8 2025-03-09 11:50:00 │ │ │ │ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 12 earthquakes │ └─────────────────────────────────────────────────────────────────────────────┘
The USGS earthquake feed returns GeoJSON: features with mag: 4.2,
place: "18 km SSE of Ridgecrest, California", depth in km. Typically
150–300 per day. Declare columns, map features to rows:
<!-- "samesite" allows fetch to the host this .melker file was loaded from --> <policy>{ "permissions": { "net": ["samesite"] } }</policy> <data-table id="quakeTable" selectable="single" style="width: fill; height: fill"> { "columns": [ { "header": "Mag", "width": 6, "align": "right" }, { "header": "Location", "width": "fill" }, { "header": "Depth km", "width": 10, "align": "right" }, { "header": "Time (UTC)", "width": 20 } ] } </data-table> <!-- $melker.url is the URL this file was loaded from. new URL('earthquakes.json', $melker.url) resolves it relative to that location. --> <script type="typescript" async="ready"> const res = await fetch(new URL('earthquakes.json', $melker.url)); const json = await res.json(); const table = $melker.getElementById('quakeTable'); json.features.sort((a, b) => b.properties.mag - a.properties.mag); table.setValue(json.features.map(f => [ f.properties.mag.toFixed(1), f.properties.place, f.geometry.coordinates[2].toFixed(1), new Date(f.properties.time).toISOString().slice(0, 19).replace('T', ' '), ])); table.props.footer = [['', json.features.length + ' earthquakes', '', '']]; $melker.render(); </script>
A table answers "what happened?" but not "what's the pattern?"
Reference: Data table
Horizontal, vertical, stacked, grouped, sparkline, or LED meter. One component, many modes.
A sparkline is <data-bars> with height: 1 and gap: 0.
One row of block characters showing the trend:
<data-bars id="spark" series='[{"name": "Magnitude"}]' style="orientation: vertical; height: 1; gap: 0"/>
Recent Earthquakes (magnitude) Magnitude:▃▄▅▂▇▂▃▃▄▆▇█
Give it more height and it becomes a full bar chart. Add labels, values, and tooltips:
<data-bars id="magSparkline" tooltip="auto" showValues="true" series='[{"name": "Magnitude"}]' style="orientation: vertical; height: fill; gap: 2; bar-width: 3; flex: 1"/> <script type="typescript" async="ready"> const res = await fetch(new URL('earthquakes.json', $melker.url)); const json = await res.json(); const sparkline = $melker.getElementById('magSparkline'); sparkline.setValue(json.features.reverse().map(f => [f.properties.mag])); sparkline.props.labels = json.features.map(f => f.properties.place.split(',')[0]); $melker.render(); </script>
Recent Earthquakes (magnitude) 2 2 3 1 5 2 2 2 3 4 4 ▂▂▂ ███ ▃▃▃ ███ ▆▆▆ ███ ▄▄▄ ███ ███ ███ ▂▂▂ ███ ███ ▆▆▆ ███ ███ ▁▁▁ ███ ███ ███ ▃▃▃ ▆▆▆ ███ ███ ███ ███ ███ ███ ▁▁▁ ███ ▅▅▅ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ 12 km9 km 45 km5 km 128 k22 km8 km 35 km14 km6 km 18 km
For multi-series comparisons, define multiple series:
<data-bars series='[{"name": "2024"}, {"name": "2025"}]' bars='[[50, 65], [60, 80], [45, 70]]' labels='["Q1", "Q2", "Q3"]' showValues="true"/>
Q1 █████████████████████████████████████████████▋ 50 ███████████████████████████████████████████████████████████▍ 65 Q2 ██████████████████████████████████████████████████████▊ 60 █████████████████████████████████████████████████████████████████████████ 80 Q3 █████████████████████████████████████████▏ 45 ███████████████████████████████████████████████████████████████▉ 70
For streaming data, appendEntry()/shiftEntry() give you a sliding window:
bars.appendEntry([newValue], label); // add to the right bars.shiftEntry(); // remove from the left
Bars show shape. For understanding spread (median, range, outliers) you need a different view.
Reference: Data bars
Pass raw values. The component computes the statistics.
Price Distribution (EUR/MWh) 107 ┤ │ ┬ │ │ │ │ 88 ┤ │ │ │ │ │ 70 ┤ ┌┴┐ E │ │ │ U │ ┬ │ │ R │ │ ├─┤ 51 ┤ │ │ │ │ ┌┴┐ └┬┘ │ ├─┤ │ 33 ┤ · └┬┘ │ │ · ┌┴┐ · ┴ │─── └┬┘ │ · · 14 ┤ └─────────────────────────────────────────────────────────────────────────── SE1 SE2 SE3 SE4
Sweden's four electricity bidding areas: SE1 (north, stable, narrow range) to SE4 (south, volatile, price spikes above 100 EUR/MWh). Pass raw price arrays and the component computes Q1, median, Q3, whiskers, and outliers:
<data-boxplot id="priceBoxplot" title="Price Distribution (EUR/MWh)" yAxisLabel="EUR" selectable="true" tooltip="auto" style="width: fill; height: fill"/> <script type="typescript" async="ready"> const res = await fetch(new URL('electricity-prices.json', $melker.url)); const json = await res.json(); const boxplot = $melker.getElementById('priceBoxplot'); boxplot.setGroups(json.areas.map(a => ({ label: a.area, values: a.prices, }))); $melker.render(); </script>
Pre-computed statistics work too. Pass stats instead of values:
boxplot.setGroups([{
label: 'SE4',
stats: { min: 20, q1: 38, median: 55, q3: 82, max: 120, outliers: [195, 210] }
}]);
Some data is two-dimensional: values that vary across rows and columns, where color carries meaning.
Reference: Data boxplot
A 2D grid of values, rendered as colored cells with optional contour lines.
9am10a11a12p1pm2pm3pm4pm Mon 12 45 78 90 85 60 40 20 Tue 15 50 82 95 88 65 45 25 Wed 10 40 70 80 75 55 35 15 Thu 18 55 85 92 90 70 50 30 Fri 8 35 65 75 70 50 30 12 8 95
Office activity through the week: Monday 9 AM reads 12 (quiet), Thursday noon reads 92
(everyone's in meetings). Eight built-in color scales: viridis,
thermal, redblue, and more:
<data-heatmap id="activity" colorScale="thermal" showValues="true" valueFormat="d" showLegend="true" style="width: fill; height: fill"/> <script type="typescript" async="ready"> const res = await fetch(new URL('activity.json', $melker.url)); const json = await res.json(); const hm = $melker.getElementById('activity'); hm.props.rowLabels = json.rowLabels; hm.props.colLabels = json.colLabels; hm.props.grid = json.grid; $melker.render(); </script>
The grid tells the story at a glance: Tuesday and Thursday noon are hot (95, 92), Friday morning and afternoon are cool (8, 12). For real-time monitoring, you don't have to replace the entire grid when one sensor updates. Write a single cell or a whole row:
const hm = $melker.getElementById('activity'); // A single sensor reading changed hm.setCell(2, 5, 73); // Monday's full row just came in from the API hm.setRow(0, [14, 48, 80, 93, 87, 62, 42, 22]); $melker.render();
For scientific data where the boundaries matter more than the cells, add contour lines.
Set isolineCount="5" and the component runs marching squares to find the
boundaries between value ranges, drawing them as lines over the color grid. Set
showCells="false" for a pure contour map:
<data-heatmap grid="..." isolineCount="5" isolineMode="nice" showIsolineLabels="true" colorScale="viridis"/>
│ ╭──╭─────╮ ─╮ ─╮ 20 ╭──╭──╭──╭─────╮ ─╮ ─╮ ─╮ 20 ╭──╭──╭──╯ ╰──╮ │ │ 40 │ │ │ ╭──╯ │ │ 40 ╰──│ │ │ │ │ │ 40 │ ╰──╰────────╯ ─╯ ─╯ │ 20 ╰──╰──╰────────╯ │ ╭──╯ 20
Grids are flat. But some data is naturally hierarchical.
Reference: Data heatmap
Hierarchical data with expand/collapse, virtual scrolling, and lazy loading.
┌───────────────────────────────────────────────────────────┬──────────┬──────┐┐ │Name │ Size│Type ││ ├───────────────────────────────────────────────────────────┼──────────┼──────┤│ │├─v src │ │ ││ ││ ├─v components │ │ ││ ││ │ ├─ button.ts │ 4.2 KB│ts ││ ││ │ ├─ input.ts │ 6.8 KB│ts ││ ││ │ └─ table.ts │ 12.1 KB│ts ││ ││ ├─v engine │ │ ││ ││ │ ├─ layout.ts │ 8.4 KB│ts ││ ││ │ ├─ render.ts │ 15.3 KB│ts ││ ││ │ └─ buffer.ts │ 5.7 KB│ts ││ ││ └─ mod.ts │ 1.2 KB│ts ││ │├─v tests │ │ ││ ││ ├─ layout.test.ts │ 3.1 KB│ts ││ ││ └─ render.test.ts │ 4.5 KB│ts ││ │├─ README.md │ 2.8 KB│md ││ │└─ deno.json │ 580 B│json ││ │ │ │ ││ └───────────────────────────────────────────────────────────┴──────────┴──────┘┘
Processes, files, org charts. Anything with parent-child relationships. Extra columns attach metadata to each node:
<data-tree id="files" selectable="single" expandAll="true" showColumnBorders="true" columns='[{"header": "Size", "width": 10, "align": "right"}, {"header": "Type", "width": 6}]' style="width: fill; height: fill; border: thin"/> <script type="typescript" async="ready"> const res = await fetch(new URL('files.json', $melker.url)); const json = await res.json(); const tree = $melker.getElementById('files'); tree.props.nodes = json.nodes; tree.expandAll(); $melker.render(); </script>
Only visible nodes are rendered, so large trees scroll without slowdown. Each component so far shows one slice. Combining them is where it gets useful.
Reference: Data tree
One attribute syncs selection across table, bars, and boxplot. Click one, all follow.
Distribution ┌─────────────────────────────────────┐ 107 ┤ │Area Min Median Max │ │ ┬ ├─────────────────────────────────────┤ │ │ │SE1 18.5 24.4 30.2 │ │ │ │SE2 21.9 28.3 35.4 │ 88 ┤ │ │SE3 27.5 39.0 58.1 │ │ │ │SE4 32.1 53.0 102.5 │ │ │ │ │ 70 ┤ ┌┴┐ │ │ │ │ │ │ │ │ ┬ │ │ └─────────────────────────────────────┘ │ │ ├─┤ SE1 █████████████▍ 24 51 ┤ │ │ │ │ ┌┴┐ └┬┘ SE2 ███████████████▋ 29 │ ├─┤ │ 33 ┤ · └┬┘ │ SE3 ██████████████████████▍ 41 │ · ┌┴┐ · ┴ │─── └┬┘ SE4 ████████████████████████████████ 58 │ · · 14 ┤ └─────────────────────────────────── SE1 SE2 SE3 SE4
Three views of the same data: box plot, table, bar chart. Click SE3 in the box plot and the table row and bar highlight. One attribute makes it work:
<data-boxplot id="priceBoxplot" title="Distribution" selectable="true" onGetId="event.label" bind:selection="selectedAreas" style="flex: 1; height: fill"/> <data-table id="statsTable" selectable="single" onGetId="event[0]" bind:selection="selectedAreas" style="width: fill; flex: 1"> { "columns": [...] } </data-table> <data-bars id="avgBars" selectable="true" onGetId="event.label" showValues="true" bind:selection="selectedAreas" style="flex: 1; height: fill"/> <script type="typescript"> $melker.createState({ selectedAreas: [] }); </script>
bind:selection="selectedAreas" creates shared state.
onGetId extracts a comparable identity from each component's data shape:
event.label for boxplot/bars, event[0] for the table's first column.
No event wiring. No manual sync.
Reference: Selection binding · State binding
Declare permissions. Fetch data. Transform. setValue. Refresh.
Every example on this page follows the same shape:
<!-- 1. Declare what you need --> <policy>{ "permissions": { "net": ["api.example.com"] } }</policy> <!-- 2. Lay out the views --> <data-table id="table" bind:selection="selected" .../> <data-bars id="chart" bind:selection="selected" .../> <!-- 3. Fetch, transform, push --> <script type="typescript" async="ready"> async function refresh() { const data = await fetch('https://api.example.com/data').then(r => r.json()); $melker.getElementById('table').setValue( data.items.map(d => [d.name, d.value, d.status]) ); $melker.getElementById('chart').setValue( data.items.map(d => [d.value]) ); } await refresh(); setInterval(refresh, 60000); </script>
Policy sets the boundary. Components are the vocabulary. The script is 20–50 lines of fetch-and-map. One file, readable before you run it.
$ melker https://melker.sh/examples/showcase/earthquake-dashboard.melker $ melker https://melker.sh/examples/showcase/electricity-dashboard.melker
Reference: All components · Script context · How it works
Every app declares what it can access. Nothing more.
Open the earthquake dashboard and the first thing you see is the policy:
<policy> { "name": "earthquake-dashboard", "permissions": { "net": ["earthquake.usgs.gov", "raw.githubusercontent.com"], "map": true } } </policy>
Two hosts. Map tiles. Nothing else. Any other network request fails. When an API requires a key, the policy declares it as configuration, not hardcoded:
<policy> { "permissions": { "net": ["api.eia.gov"] }, "configSchema": { "eia.apiKey": { "type": "string", "env": "EIA_API_KEY" } } } </policy>
Deep dive: The Policy System · Manifesto