Data Visualization Components

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.

1. Tables

Fetch JSON, map to rows, call setValue. Sorting, scrolling, and selection are built in.

melker --stdout examples/dataviz/table.melker
┌─────────────────────────────────────────────────────────────────────────────┐
   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                                                        
└─────────────────────────────────────────────────────────────────────────────┘

View source · Data

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:

table.melker
<!-- "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

2. Bar charts

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:

sparkline.melker
<data-bars id="spark"
    series='[{"name": "Magnitude"}]'
    style="orientation: vertical; height: 1; gap: 0"/>
melker --stdout examples/dataviz/sparkline.melker
 Recent Earthquakes (magnitude)
Magnitude:▃▄▅▂▇▂▃▃▄▆▇█

Give it more height and it becomes a full bar chart. Add labels, values, and tooltips:

bars.melker
<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>
melker --stdout examples/dataviz/bars.melker
 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

View source · Data

For multi-series comparisons, define multiple series:

Grouped bars: side-by-side comparison
<data-bars
    series='[{"name": "2024"}, {"name": "2025"}]'
    bars='[[50, 65], [60, 80], [45, 70]]'
    labels='["Q1", "Q2", "Q3"]'
    showValues="true"/>
melker --stdout examples/dataviz/grouped-bars.melker
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

3. Box plots

Pass raw values. The component computes the statistics.

melker --stdout examples/dataviz/boxplot.melker
                          Price Distribution (EUR/MWh)
107 
                 
                 
                 
 88              
                 
                 
 70             ┌┴┐
E                
U               
R              ├─┤
 51             
            ┌┴┐ └┬┘
            ├─┤  
 33      ·  └┬┘  
     ·  ┌┴┐  ·   
    ─── └┬┘
     ·   ·
 14 
    └───────────────────────────────────────────────────────────────────────────
     SE1 SE2 SE3 SE4

View source · Data

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:

boxplot.melker
<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

4. Heatmaps

A 2D grid of values, rendered as colored cells with optional contour lines.

melker --stdout examples/dataviz/heatmap.melker
    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

View source · Data

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:

heatmap.melker
<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:

Partial updates
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"/>
melker --stdout examples/dataviz/isolines.melker
                                   
     ╭──╭─────╮ ─╮ ─╮ 20         
                                   
 ╭──╭──╭──╭─────╮ ─╮ ─╮ ─╮ 20      
                                   
 ╭──╭──╭──╯     ╰──╮   40      
                                   
          ╭──╯   40      
                                   
 ╰──│            40      
                                   
  ╰──╰────────╯ ─╯ ─╯  20      
                                   
 ╰──╰──╰────────╯   ╭──╯ 20      
                                   

Grids are flat. But some data is naturally hierarchical.

Reference: Data heatmap

5. Trees

Hierarchical data with expand/collapse, virtual scrolling, and lazy loading.

melker --stdout examples/dataviz/tree.melker
┌───────────────────────────────────────────────────────────┬──────────┬──────┐┐
Name                                                             SizeType  ││
├───────────────────────────────────────────────────────────┼──────────┼──────┤│
│├─v src                                                    │          │      │
 ├─v components                                                           ││
  ├─  button.ts                                              4.2 KBts    ││
  ├─  input.ts                                               6.8 KBts    ││
  └─  table.ts                                              12.1 KBts    ││
 ├─v engine                                                               ││
  ├─  layout.ts                                              8.4 KBts    ││
  ├─  render.ts                                             15.3 KBts    ││
  └─  buffer.ts                                              5.7 KBts    ││
 └─  mod.ts                                                   1.2 KBts    ││
├─v tests                                                                  ││
 ├─  layout.test.ts                                           3.1 KBts    ││
 └─  render.test.ts                                           4.5 KBts    ││
├─  README.md                                                  2.8 KBmd    ││
└─  deno.json                                                   580 Bjson  ││
                                                                           ││
└───────────────────────────────────────────────────────────┴──────────┴──────┘┘

View source · Data

Processes, files, org charts. Anything with parent-child relationships. Extra columns attach metadata to each node:

tree.melker
<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

6. Connecting views

One attribute syncs selection across table, bars, and boxplot. Click one, all follow.

melker --stdout examples/dataviz/connected.melker
              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

View source · Data

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:

connected.melker
<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

7. The pattern

Declare permissions. Fetch data. Transform. setValue. Refresh.

Every example on this page follows the same shape:

The recurring 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

8. Policy details

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