H3 Visualization
Using Fused Map to visualize hexagons
The fused-map widget draws H3 cells as hexagons, from a UDF or from an indexed dataset.
From a UDF

An h3 layer draws the table a UDF returns. "{{point_count_to_hex}}" references the UDF and h3Column names the cell column.
Code
UDF
import fused
@fused.udf
def udf(
res: int = 9
):
noise_311_link = "https://gist.githubusercontent.com/kashuk/670a350ea1f9fc543c3f6916ab392f62/raw/4c5ced45cc94d5b00e3699dd211ad7125ee6c4d3/NYC311_noise.csv"
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
con = common.duckdb_connect()
df = con.execute(
"""
SELECT
h3_latlng_to_cell(lat, lng, ?) AS hex,
COUNT(*) AS cnt
FROM read_csv_auto(?)
WHERE lat IS NOT NULL AND lng IS NOT NULL
GROUP BY 1
""",
[res, noise_311_link],
).df()
return df
Widget
{
"type": "fused-map",
"props": {
"showSidebar": true,
"layers": [
{
"id": "point_count_to_hex",
"name": "311 Noise Complaints",
"udf": "{{point_count_to_hex}}",
"type": "h3",
"h3Column": "hex",
"tooltip": true,
"legend": true,
"style": {
"fillColor": {
"type": "continuous",
"attr": "cnt",
"palette": "OrYel",
"domain": [
0,
200
]
},
"opacity": 0.7
}
}
]
}
}
From an indexed dataset
A fused-hex layer reads an indexed dataset from GET /h3/query, one request per map tile, with no UDF.
Code
{
"type": "fused-map",
"props": {
"basemap": "mapbox://styles/mapbox/dark-v11",
"showSidebar": true,
"layers": [
{
"id": "nyc311",
"name": "NYC 311 requests (Jan 2024)",
"type": "fused-hex",
"h3Column": "hex",
"source": {
"endpoint": "https://www.fused.io/server/v1/h3/query",
"path": "s3://fused-asset/data/nyc311/",
"timeColumn": "created_date",
"dateMin": "2024-01-01",
"dateMax": "2024-01-31T23:59:59",
"variables": [
"year"
],
"reduce": "sum",
"overview": {}
},
"tooltip": [
"hex",
"count",
"date_min",
"date_max"
],
"legend": {
"title": "311 requests"
},
"style": {
"fillColor": {
"type": "continuous",
"attr": "count",
"palette": "Sunset",
"domain": [
0,
50
]
},
"opacity": 0.8
}
}
],
"centerLng": -73.95,
"centerLat": 40.72,
"zoom": 11,
"autoFit": false
}
}
Every key under props is documented in the Fused Map widget reference.
Interactive Visualizations in Canvas
In Fused Canvas you can create interactive visualizations by combining UDFs together.
We can create a more advanced version of these visualizations using by creating UDFs that leverage postMessages to talk to each other.
Connecting Histogram & Map
Launch the interactive Crop Exploration Dashboard →
We provide these UDFs for now as-is. You can duplicate them and modify them to work with your own data:
Canvas node iframes run with an opaque origin, so a client-side fetch() from one node's HTML back to another node's URL is blocked by CORS. Each UDF below instead computes its data server-side with fused.load() and embeds the result directly in the page as JSON. Cross-component sync (the histogram brush filtering the map, the map's viewport filtering the histograms) still works over postMessage/BroadcastChannel, which isn't subject to CORS.
Range Histogram UDF
The Range Histogram UDF allows you to visualize the distribution of a continuous variable (like elevation or temperature) as an interactive histogram. You can select a range to filter the data on the map.
# doctest: skip
# Pairs with join_era5_cdl_elevation.py -- bump this note if that UDF's schema changes,
# so this UDF's own cached result gets invalidated and re-executed too.
@fused.udf
def udf(
bounds: list = [-125.0, 32.0, -114.0, 42.0],
res: int = 5,
year: int = 2024,
month: str = "2024-05",
theme: str = "workbench",
dataset: str = "all", # IMPORTANT: match the map’s DATASET or use "all"
num_bins: int = 50,
value_field: str = "elevation",
title: str = "Elevation",
channel: str = "fused-elevation-temp" # IMPORTANT: match the map’s CHANNEL
):
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
# Compute the joined dataset server-side and embed it directly in the page as
# plain JSON -- canvas node iframes run with an opaque origin, so a client-side
# fetch() back to another node's URL is blocked by CORS with no way to allow it
# from here. The dataset is small (a few thousand rows), so there's no need for
# a client-side SQL engine either -- plain JS array filtering is enough and has
# far fewer moving parts (no WASM, no Worker, no CORS, no Parquet compatibility
# surface) to fail silently inside a sandboxed iframe.
join_era5_cdl_elevation = fused.load("join_era5_cdl_elevation")
df = join_era5_cdl_elevation(bounds=bounds, res=res, year=year, month=month)
df = df.copy()
df["hex"] = df["hex"].astype(str) # preserve full 64-bit H3 id precision through JSON (JS numbers only hold 53 bits)
records_json = df.to_json(orient="records")
themes = {
'workbench': {
'background': '#1a1a1a',
'text': '#ffffff',
'accent': '#E8FF59',
'chart_bg': '#2a2a2a'
}
}
selected = themes.get(theme, themes['workbench'])
safe_title = (
title.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'")
)
html_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>""" + safe_title + """</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* { margin: 0; padding: 0; border: none; outline: none; box-sizing: border-box; }
body {
padding: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: """ + selected['background'] + """;
color: """ + selected['text'] + """;
height: 100vh; display: flex; flex-direction: column;
}
.header { text-align: center; margin-bottom: 10px; flex-shrink: 0; }
.title { font-size: 16px; color: """ + selected['accent'] + """; margin-bottom: 6px; font-weight: 600; }
.chart-container { flex: 1; position: relative; background: """ + selected['chart_bg'] + """; border-radius: 6px; padding: 10px; margin-bottom: 10px; min-height: 200px; }
.selection-info { text-align: center; font-size: 11px; color: """ + selected['accent'] + """; margin-bottom: 8px; min-height: 14px; flex-shrink: 0; }
.controls { text-align: center; flex-shrink: 0; margin-bottom: 10px; display: flex; flex-direction: column; gap: 8px; }
.controls-row { display: flex; justify-content: center; align-items: center; gap: 10px; }
.btn { background: """ + selected['accent'] + """; color: """ + selected['background'] + """; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 600; margin: 0 4px; transition: opacity 0.2s; }
.btn:hover { opacity: 0.8; }
.status { text-align: center; font-size: 10px; opacity: 0.7; flex-shrink: 0; }
.loading-state { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; opacity: 0.6; }
.bar { fill: """ + selected['accent'] + """; transition: fill 0.2s, opacity 0.2s; cursor: pointer; }
.bar.selected { fill: """ + selected['accent'] + """; opacity: 1; }
.bar.unselected { fill: rgba(232, 255, 89, 0.2); stroke: none; opacity: 1; }
.bar:hover { opacity: 0.8; }
.tooltip { position: absolute; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 1000; display: none; border: 1px solid #444; }
</style>
</head>
<body>
<div class="tooltip" id="tooltip"></div>
<div class="header">
<div class="title">""" + safe_title + """</div>
</div>
<div class="chart-container" id="chartContainer">
<div id="loadingState" class="loading-state">
<div style="font-size: 24px;">📊</div>
<div style="margin-top: 8px; font-weight: 500;">Loading dataset</div>
</div>
<svg id="histogram" style="display: none; width: 100%; height: 100%;"></svg>
</div>
<div class="histogram-minmax" id="histogramMinMax" style="display: flex; justify-content: space-between; font-size: 11px; color: """ + selected['accent'] + """; margin-bottom: 8px; min-height: 14px;">
<span id="histMin"></span><span id="histMax"></span>
</div>
<div class="selection-info" id="selectionInfo"></div>
<div class="controls">
<div class="controls-row">
<button class="btn" onclick="clearSelection()">Clear Selection</button>
</div>
</div>
<div class="status" id="status">Loading…</div>
<script>
// --- config ---
const componentId = 'histogram-v4-' + Math.random().toString(36).substr(2, 9);
const RECORDS = """ + records_json + """;
const DATASET = """ + repr(dataset) + """;
const NUM_BINS = """ + str(num_bins) + """;
const VALUE_FIELD = """ + repr(value_field) + """;
const CHANNEL = """ + repr(channel) + """; // must match map
// --- tiny bus: BroadcastChannel + postMessage fanout ---
let bc = null;
try { if ('BroadcastChannel' in window) bc = new BroadcastChannel(CHANNEL); } catch (e) {}
function busSend(obj) {
const s = JSON.stringify(obj);
try { if (bc) bc.postMessage(obj); } catch(e) {}
try { window.parent.postMessage(s, '*'); } catch(e) {}
try { if (window.top && window.top !== window.parent) window.top.postMessage(s, '*'); } catch(e) {}
try {
if (window.top && window.top.frames) {
for (let i=0; i<window.top.frames.length; i++) {
const f = window.top.frames[i];
if (f !== window) try { f.postMessage(s, '*'); } catch(e) {}
}
}
} catch(e) {}
}
function getFeatureValue(feature, fieldName = VALUE_FIELD) {
if (!feature) return 0;
if (feature[fieldName] !== undefined) {
const direct = Number(feature[fieldName]);
if (Number.isFinite(direct)) return direct;
}
if (feature.properties && feature.properties[fieldName] !== undefined) {
const propVal = Number(feature.properties[fieldName]);
if (Number.isFinite(propVal)) return propVal;
}
return 0;
}
// --- state ---
let fullDataset = [];
let filteredDataset = [];
let activeFilters = new Map();
let dynamicBins = [];
let svg = null, chartGroup = null, barsGroup = null, xScale = null, yScale = null, brush = null;
let selectedBins = [];
let chartWidth = 0, chartHeight = 0;
const RANGE_DEBOUNCE_MS = 50;
let rangeDebounceTimer = null;
let pendingRangeSelection = null;
let currentMinValue = null;
let currentMaxValue = null;
// UI handles
const loadingState = document.getElementById('loadingState');
const histogramSvg = document.getElementById('histogram');
const statusElement = document.getElementById('status');
// --- utils ---
function formatValue(val) { return (val >= 1000) ? `${(val/1000).toFixed(1)}k ` : `${val.toFixed(1)} `; }
// --- bins ---
function calculateDynamicBins(data) {
if (!data || data.length === 0) return [];
const areas = data.map(f => getFeatureValue(f)).filter(a => a > 0).sort((a,b)=>a-b);
if (!areas.length) return [];
const min = areas[0], max = areas[areas.length - 1];
const logMin = Math.log10(min), logMax = Math.log10(max), logStep = (logMax - logMin) / NUM_BINS;
const bins = [];
for (let i=0;i<NUM_BINS;i++){
const binMin = Math.pow(10, logMin + i*logStep);
const binMax = Math.pow(10, logMin + (i+1)*logStep);
const label = binMax >= 1000 ? `${(binMin/1000).toFixed(1)}-${(binMax/1000).toFixed(1)} `
: `${binMin.toFixed(1)}-${binMax.toFixed(1)} `;
bins.push({ label, min:binMin, max:binMax, count:0 });
}
return bins;
}
function processRawData(raw) {
if (raw?.features && Array.isArray(raw.features)) {
return raw.features.map(f => {
const areaMeters = f.properties?.area_meters || 0;
const areaKm = areaMeters / 1_000_000;
let valueCandidate;
if (f.properties && Object.prototype.hasOwnProperty.call(f.properties, VALUE_FIELD)) {
valueCandidate = f.properties[VALUE_FIELD];
} else if (VALUE_FIELD === 'count_mmsi') {
valueCandidate = areaKm;
} else {
valueCandidate = f.properties?.value ?? f.properties?.cnt ?? areaKm;
}
const numericValue = Number(valueCandidate);
const resolvedValue = Number.isFinite(numericValue) ? numericValue : 0;
const props = Object.assign({}, f.properties, { count_mmsi: areaKm, [VALUE_FIELD]: resolvedValue });
return {
id: f.properties?.id || Math.random().toString(36),
geometry: f.geometry,
lng: f.geometry?.coordinates?.[0],
lat: f.geometry?.coordinates?.[1],
properties: props,
count_mmsi: areaKm,
area_meters: areaMeters,
cnt: f.properties?.cnt || 0,
[VALUE_FIELD]: resolvedValue
};
});
} else if (Array.isArray(raw)) {
return raw.map(item => {
const valueCandidate = item[VALUE_FIELD] ?? item.value ?? item.count ?? item.count_mmsi ?? 0;
const numericValue = Number(valueCandidate);
const resolvedValue = Number.isFinite(numericValue) ? numericValue : 0;
const copy = Object.assign({}, item);
copy[VALUE_FIELD] = resolvedValue;
return copy;
});
}
return [];
}
// --- external filters application (so histogram reflects spatial from map) ---
function applyAllFilters() {
let filtered = [...fullDataset];
for (let [,f] of activeFilters.entries()) {
const next = applyFilter(filtered, f);
// The map's viewport is broadcast as a spatial filter on every pan/zoom,
// but this dataset is a fixed snapshot, not a live re-fetch -- zooming
// into any sub-area with no matching points would otherwise blank the
// chart with no way back except re-running the UDF. Ignore a spatial
// filter specifically when it would wipe out everything.
if (f.type === 'spatial' && next.length === 0 && filtered.length > 0) continue;
filtered = next;
}
filteredDataset = filtered;
updateHistogram(false);
const summary = `${filtered.length}/${fullDataset.length} features (${activeFilters.size} filters)`;
statusElement.textContent = summary;
}
function applyFilter(data, filter) {
switch (filter.type) {
case 'spatial': return applySpatialFilter(data, filter);
case 'range':
case 'multi-range': return applyRangeFilter(data, filter);
case 'categorical': return applyCategoricalFilter(data, filter);
default: return data;
}
}
function applySpatialFilter(data, filter) {
const b = filter.bounds; if (!b) return data;
return data.filter(f => f.lng>=b.sw.lng && f.lng<=b.ne.lng && f.lat>=b.sw.lat && f.lat<=b.ne.lat);
}
function applyRangeFilter(data, filter) {
const field = filter.field || VALUE_FIELD;
const [min,max] = filter.values || [0, Number.POSITIVE_INFINITY];
return data.filter(f => {
const val = getFeatureValue(f, field);
return val >= min && val <= max;
});
}
function applyCategoricalFilter(data, filter) {
const field = filter.field || 'category';
const values = filter.values || [];
return data.filter(f => values.includes(f[field]));
}
function clearAllFilters() {
activeFilters.clear();
filteredDataset = [...fullDataset];
updateHistogram();
statusElement.textContent = `${fullDataset.length} features (no filters)`;
// unified clear + legacy clear
busSend({ type:'filter', fromComponent:componentId, timestamp:Date.now(), dataset:DATASET, filter:{ type:'clear', clearAll:true } });
busSend({ type:'filter_cleared', source:componentId, timestamp:Date.now() });
}
// --- UI state ---
function setUIState(state) {
loadingState.style.display = state === 'loading' ? 'flex' : 'none';
histogramSvg.style.display = state === 'chart' ? 'block' : 'none';
}
// --- chart ---
function initializeChart() {
d3.select('#histogram').selectAll('*').remove();
const container = document.getElementById('chartContainer');
chartWidth = container.clientWidth - 20;
chartHeight = container.clientHeight - 20;
svg = d3.select('#histogram').attr('width', chartWidth).attr('height', chartHeight);
chartGroup = svg.append('g').attr('class', 'chart-group');
barsGroup = svg.append('g').attr('class', 'bars-group');
xScale = d3.scaleBand().domain(dynamicBins.map((_,i)=>i)).range([0, chartWidth]).padding(0.1);
yScale = d3.scaleLinear().domain([0, d3.max(dynamicBins, d => d.count)]).range([chartHeight, 0]).nice();
setupBrush();
createBars();
}
function createBars() {
barsGroup.selectAll('.bar')
.data(dynamicBins).enter().append('rect').attr('class','bar')
.attr('x', (d,i)=>xScale(i))
.attr('y', d=>yScale(d.count))
.attr('width', xScale.bandwidth())
.attr('height', d=>chartHeight - yScale(d.count))
.on('mouseover', function(evt,d){
d3.select(this).style('opacity',0.8);
const t = document.getElementById('tooltip');
t.innerHTML = `<div><strong>Range:</strong> ${formatValue(d.min)} - ${formatValue(d.max)}</div><div><strong>Count:</strong> ${d.count} areas</div>`;
t.style.display = 'block'; t.style.left = (evt.pageX+15)+'px'; t.style.top = (evt.pageY-10)+'px';
})
.on('mousemove', function(evt){ const t=document.getElementById('tooltip'); t.style.left=(evt.pageX+15)+'px'; t.style.top=(evt.pageY-10)+'px'; })
.on('mouseout', function(){ d3.select(this).style('opacity',1); document.getElementById('tooltip').style.display='none'; });
}
function setupBrush() {
brush = d3.brushX().extent([[0,0],[chartWidth,chartHeight]]).on('start brush end', brushed);
const brushGroup = chartGroup.append('g').attr('class','brush').call(brush);
brushGroup.select('.selection').style('fill','rgba(128,128,128,0.25)').style('stroke','#999').style('stroke-width','0.5px').style('rx','3px');
brushGroup.select('.overlay').style('cursor','crosshair').style('fill','none');
}
function brushed(event) {
const sel = event.selection;
if (!sel) {
selectedBins = [];
updateBarSelection(); updateSelectionInfo();
pendingRangeSelection = null;
if (rangeDebounceTimer) {
clearTimeout(rangeDebounceTimer);
rangeDebounceTimer = null;
}
if (event.type === 'end') clearRangeFilter();
return;
}
const [x0,x1] = sel;
selectedBins = [];
dynamicBins.forEach((bin, idx) => {
const left = xScale(idx), right = left + xScale.bandwidth();
if (right > x0 && left < x1) selectedBins.push(idx);
});
updateBarSelection();
updateSelectionInfo();
if ((event.type === 'brush' || event.type === 'end') && selectedBins.length > 0) {
const minArea = Math.min(...selectedBins.map(i => dynamicBins[i]?.min ?? Infinity));
const maxArea = Math.max(...selectedBins.map(i => dynamicBins[i]?.max ?? -Infinity));
if (Number.isFinite(minArea) && Number.isFinite(maxArea)) {
scheduleRangeFilter(minArea, maxArea);
}
}
}
function updateBarSelection() {
if (!barsGroup) return;
barsGroup.selectAll('.bar')
.attr('class', (d,i) => selectedBins.length===0 ? 'bar' : (selectedBins.includes(i) ? 'bar selected' : 'bar unselected'));
}
function updateHistogram(animate=false) {
const data = filteredDataset;
if (!data.length) {
currentMinValue = null;
currentMaxValue = null;
updateMinMaxLabels();
setUIState('loading');
statusElement.textContent = 'No data matches current filters';
return;
}
let minVal = Infinity;
let maxVal = -Infinity;
dynamicBins.forEach(b => b.count = 0);
data.forEach(f => {
const value = getFeatureValue(f);
if (Number.isFinite(value)) {
if (value < minVal) minVal = value;
if (value > maxVal) maxVal = value;
}
for (let b of dynamicBins) {
if (value >= b.min && (value < b.max || b === dynamicBins[dynamicBins.length - 1])) { b.count++; break; }
}
});
currentMinValue = Number.isFinite(minVal) ? minVal : null;
currentMaxValue = Number.isFinite(maxVal) ? maxVal : null;
updateMinMaxLabels();
if (!svg) {
initializeChart();
setUIState('chart');
return;
}
yScale.domain([0, d3.max(dynamicBins, d => d.count)]).nice();
const bars = barsGroup.selectAll('.bar').data(dynamicBins);
if (animate) {
bars.transition().duration(200).attr('y', d=>yScale(d.count)).attr('height', d=>chartHeight - yScale(d.count));
} else {
bars.attr('y', d=>yScale(d.count)).attr('height', d=>chartHeight - yScale(d.count));
}
updateBarSelection();
setUIState('chart');
}
function updateSelectionInfo() {
const el = document.getElementById('selectionInfo');
if (!selectedBins.length) { el.textContent=''; return; }
const total = selectedBins.reduce((s,i)=>s+dynamicBins[i].count,0);
const minArea = Math.min(...selectedBins.map(i=>dynamicBins[i].min));
const maxArea = Math.max(...selectedBins.map(i=>dynamicBins[i].max));
const rangeLabel = maxArea >= 1000 ? `${(minArea/1000).toFixed(1)}-${(maxArea/1000).toFixed(1)} ` : `${minArea.toFixed(1)}-${maxArea.toFixed(1)} `;
el.textContent = selectedBins.length===1
? `Selected: ${dynamicBins[selectedBins[0]].label} (${total} areas)`
: `Selected: ${selectedBins.length} ranges (${rangeLabel}, ${total} areas)`;
}
// --- selection actions -> send filters to others ---
function scheduleRangeFilter(minArea, maxArea) {
pendingRangeSelection = { min: minArea, max: maxArea };
if (rangeDebounceTimer) clearTimeout(rangeDebounceTimer);
rangeDebounceTimer = setTimeout(() => {
rangeDebounceTimer = null;
const payload = pendingRangeSelection;
pendingRangeSelection = null;
if (!payload) return;
sendRangeFilter(payload.min, payload.max);
}, RANGE_DEBOUNCE_MS);
}
function sendRangeFilter(minArea, maxArea) {
if (!Number.isFinite(minArea) || !Number.isFinite(maxArea)) return;
const lower = Math.min(minArea, maxArea);
const upper = Math.max(minArea, maxArea);
statusElement.textContent = `Selected ${formatValue(lower)} – ${formatValue(upper)}`;
// unified
busSend({
type:'filter',
fromComponent:componentId,
timestamp:Date.now(),
dataset:DATASET,
filter:{ type:'range', field:VALUE_FIELD, values:[lower, upper] }
});
// legacy
busSend({
type:'range_filter',
field:VALUE_FIELD,
min:lower,
max:upper,
source:componentId,
timestamp:Date.now()
});
}
function clearSelection() {
selectedBins = [];
if (chartGroup && brush) chartGroup.select('.brush').call(brush.clear);
updateBarSelection(); updateSelectionInfo(); clearRangeFilter();
}
function clearRangeFilter() {
if (rangeDebounceTimer) {
clearTimeout(rangeDebounceTimer);
rangeDebounceTimer = null;
}
pendingRangeSelection = null;
statusElement.textContent = 'Range cleared';
// unified
busSend({
type:'filter',
fromComponent:componentId,
timestamp:Date.now(),
dataset:DATASET,
filter:{ type:'clear', field:VALUE_FIELD }
});
// legacy
busSend({ type:'filter_cleared', source:componentId, field:VALUE_FIELD, timestamp:Date.now() });
}
// --- incoming messages (so histogram reacts to map spatial) ---
function onMessage(message) {
try {
if (typeof message === 'string') { try { message = JSON.parse(message); } catch (_) {} }
if (!message) return;
// ignore our own
if (message.fromComponent && message.fromComponent === componentId) return;
if (message.source && message.source === componentId) return;
// unified
if (message.type === 'filter' && message.filter) {
// dataset scoping
if (message.dataset && DATASET !== 'all' && message.dataset !== DATASET) return;
const f = message.filter;
if (f.type === 'clear') {
if (f.clearAll) activeFilters.clear();
else if (f.field) {
for (const k of Array.from(activeFilters.keys())) if (k.includes(`_${f.field}`)) activeFilters.delete(k);
} else if (f.filterId) activeFilters.delete(f.filterId);
applyAllFilters(); return;
}
if (f.type === 'spatial' && Array.isArray(f.values)) {
const [w,s,e,n] = f.values;
activeFilters.set(`spatial_${message.fromComponent||'unknown'}`, { type:'spatial', bounds:{ sw:{lng:w,lat:s}, ne:{lng:e,lat:n} } });
applyAllFilters(); return;
}
if (f.type === 'range' && Array.isArray(f.values)) {
const field = f.field || VALUE_FIELD;
activeFilters.set(`range_${message.fromComponent||'unknown'}_${field}`, { type:'range', field, values:f.values });
applyAllFilters(); return;
}
}
// legacy
if (message.type === 'spatial_filter' && message.bounds) {
activeFilters.set(`spatial_legacy_${message.source||'unknown'}`, { type:'spatial', bounds: message.bounds });
applyAllFilters(); return;
}
if (message.type === 'range_filter' && typeof message.min !== 'undefined' && typeof message.max !== 'undefined') {
const field = message.field || VALUE_FIELD;
activeFilters.set(`range_legacy_${message.source||'unknown'}_${field}`, { type:'range', field, values:[+message.min, +message.max] });
applyAllFilters(); return;
}
if (message.type === 'filter_cleared') {
if (message.field) {
for (const k of Array.from(activeFilters.keys())) if (k.includes(`_${message.field}`)) activeFilters.delete(k);
} else if (message.source) {
for (const k of Array.from(activeFilters.keys())) if (k.includes(`_${message.source}`)) activeFilters.delete(k);
} else activeFilters.clear();
applyAllFilters(); return;
}
} catch (e) { /* swallow */ }
}
if (bc) bc.onmessage = (ev) => onMessage(ev.data);
window.addEventListener('message', (ev) => onMessage(ev.data));
// --- init: data is embedded, so this is synchronous, no network/WASM involved ---
try {
fullDataset = processRawData(RECORDS);
dynamicBins = calculateDynamicBins(fullDataset);
applyAllFilters();
} catch (e) {
console.error(e);
statusElement.textContent = `Init failed: ${e && e.message ? e.message : e}`;
}
setTimeout(() => {
busSend({
type: 'component_ready',
componentType: 'histogram',
componentId: componentId,
capabilities: ['filter'],
dataSource: 'independent',
protocol: 'unified'
});
}, 300);
function updateMinMaxLabels() {
const minEl = document.getElementById('histMin');
const maxEl = document.getElementById('histMax');
if (!minEl || !maxEl) return;
if (!Number.isFinite(currentMinValue) || !Number.isFinite(currentMaxValue)) {
minEl.textContent = '';
maxEl.textContent = '';
return;
}
minEl.textContent = formatValue(currentMinValue);
maxEl.textContent = formatValue(currentMaxValue);
}
</script>
</body>
</html>
"""
return common.html_to_obj(html_content)
Categorical Bar Chart UDF
The Categorical Bar Chart UDF is used to visualize counts of each category (such as crop types). Clicking a bar will filter the map to display only the selected category.
# doctest: skip
# Pairs with join_era5_cdl_elevation.py -- bump this note if that UDF's schema changes,
# so this UDF's own cached result gets invalidated and re-executed too.
@fused.udf
def udf(
bounds: list = [-125.0, 32.0, -114.0, 42.0],
res: int = 5,
year: int = 2024,
month: str = "2024-05",
theme: str = "workbench",
dataset: str = "all",
value_field: str = "crop_rank_1",
title: str = "Top 10 Crop Types",
channel: str = "fused-elevation-temp"
):
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
# Compute the joined dataset server-side and embed it directly in the page as
# plain JSON -- canvas node iframes run with an opaque origin, so a client-side
# fetch() back to another node's URL is blocked by CORS with no way to allow it
# from here. The dataset is small (a few thousand rows), so there's no need for
# a client-side SQL engine either -- plain JS array filtering is enough and has
# far fewer moving parts (no WASM, no Worker, no CORS, no Parquet compatibility
# surface) to fail silently inside a sandboxed iframe.
join_era5_cdl_elevation = fused.load("join_era5_cdl_elevation")
df = join_era5_cdl_elevation(bounds=bounds, res=res, year=year, month=month)
df = df.copy()
df["hex"] = df["hex"].astype(str) # preserve full 64-bit H3 id precision through JSON (JS numbers only hold 53 bits)
records_json = df.to_json(orient="records")
# USDA CDL classification codes -> crop name.
crop_mapping = {
1: "Corn", 2: "Cotton", 3: "Rice", 4: "Sorghum", 5: "Soybeans", 6: "Sunflower",
10: "Peanuts", 11: "Tobacco", 12: "Sweet Corn", 13: "Pop or Orn Corn", 14: "Mint",
21: "Barley", 22: "Durum Wheat", 23: "Spring Wheat", 24: "Winter Wheat",
25: "Other Small Grains", 26: "Dbl Crop WinWht/Soybeans", 27: "Rye", 28: "Oats",
29: "Millet", 30: "Speltz", 31: "Canola", 32: "Flaxseed", 33: "Safflower",
34: "Rape Seed", 35: "Mustard", 36: "Alfalfa", 37: "Other Hay/Non Alfalfa",
38: "Camelina", 39: "Buckwheat", 41: "Sugarbeets", 42: "Dry Beans", 43: "Potatoes",
44: "Other Crops", 45: "Sugarcane", 46: "Sweet Potatoes", 47: "Misc Vegs & Fruits",
48: "Watermelons", 49: "Onions", 50: "Cucumbers", 51: "Chick Peas", 52: "Lentils",
53: "Peas", 54: "Tomatoes", 55: "Caneberries", 56: "Hops", 57: "Herbs",
58: "Clover/Wildflowers", 59: "Sod/Grass Seed", 60: "Switchgrass",
61: "Fallow/Idle Cropland", 63: "Forest", 64: "Shrubland", 65: "Barren",
66: "Cherries", 67: "Peaches", 68: "Apples", 69: "Grapes", 70: "Christmas Trees",
71: "Other Tree Crops", 72: "Citrus", 74: "Pecans", 75: "Almonds", 76: "Walnuts",
77: "Pears", 81: "Clouds/No Data", 82: "Developed", 83: "Water", 87: "Wetlands",
88: "Nonag/Undefined", 92: "Aquaculture", 111: "Open Water",
112: "Perennial Ice/Snow", 121: "Developed/Open Space",
122: "Developed/Low Intensity", 123: "Developed/Med Intensity",
124: "Developed/High Intensity", 131: "Barren", 141: "Deciduous Forest",
142: "Evergreen Forest", 143: "Mixed Forest", 152: "Shrubland",
176: "Grass/Pasture", 190: "Woody Wetlands", 195: "Herbaceous Wetlands",
204: "Pistachios", 205: "Triticale", 206: "Carrots", 207: "Asparagus",
208: "Garlic", 209: "Cantaloupes", 210: "Prunes", 211: "Olives", 212: "Oranges",
213: "Honeydew Melons", 214: "Broccoli", 216: "Peppers", 217: "Pomegranates",
218: "Nectarines", 219: "Greens", 220: "Plums", 221: "Strawberries",
222: "Squash", 223: "Apricots", 224: "Vetch", 225: "Dbl Crop WinWht/Corn",
226: "Dbl Crop Oats/Corn", 227: "Lettuce", 229: "Pumpkins",
230: "Dbl Crop Lettuce/Durum Wht", 231: "Dbl Crop Lettuce/Cantaloupe",
232: "Dbl Crop Lettuce/Cotton", 233: "Dbl Crop Lettuce/Barley",
234: "Dbl Crop Durum Wht/Sorghum", 235: "Dbl Crop Barley/Sorghum",
236: "Dbl Crop WinWht/Sorghum", 237: "Dbl Crop Barley/Corn",
238: "Dbl Crop WinWht/Cotton", 239: "Dbl Crop Soybeans/Cotton",
240: "Dbl Crop Soybeans/Oats", 241: "Dbl Crop Corn/Soybeans",
242: "Blueberries", 243: "Cabbage", 244: "Cauliflower", 245: "Celery",
246: "Radishes", 247: "Turnips", 248: "Eggplants", 249: "Gourds",
250: "Cranberries", 254: "Dbl Crop Barley/Soybeans",
}
# Convert to JavaScript object string
crop_mapping_js = "{\n"
for crop_id, crop_name in crop_mapping.items():
# Escape single quotes in crop names
safe_name = str(crop_name).replace("'", "\\'")
crop_mapping_js += f" {crop_id}: '{safe_name}',\n"
crop_mapping_js += " }"
themes = {
'workbench': {
'background': '#1a1a1a',
'text': '#ffffff',
'accent': '#E8FF59',
'chart_bg': '#2a2a2a'
}
}
selected = themes.get(theme, themes['workbench'])
safe_title = (
title.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'")
)
html_content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>""" + safe_title + """</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* { margin: 0; padding: 0; border: none; outline: none; box-sizing: border-box; }
body {
padding: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: """ + selected['background'] + """;
color: """ + selected['text'] + """;
height: 100vh; display: flex; flex-direction: column;
}
.header { text-align: center; margin-bottom: 10px; flex-shrink: 0; }
.title { font-size: 16px; color: """ + selected['accent'] + """; margin-bottom: 6px; font-weight: 600; }
.most-common-crop {
text-align: center;
font-size: 18px;
color: """ + selected['accent'] + """;
margin-bottom: 10px;
padding: 10px;
background: """ + selected['chart_bg'] + """;
border-radius: 6px;
font-weight: 700;
min-height: 45px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-container { flex: 1; position: relative; background: """ + selected['chart_bg'] + """; border-radius: 6px; padding: 20px 10px 40px 10px; margin-bottom: 10px; min-height: 200px; overflow: hidden; }
.selection-info { text-align: center; font-size: 11px; color: """ + selected['accent'] + """; margin-bottom: 8px; min-height: 14px; flex-shrink: 0; }
.controls { text-align: center; flex-shrink: 0; margin-bottom: 10px; display: flex; flex-direction: column; gap: 8px; }
.controls-row { display: flex; justify-content: center; align-items: center; gap: 10px; }
.btn { background: """ + selected['accent'] + """; color: """ + selected['background'] + """; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 600; margin: 0 4px; transition: opacity 0.2s; }
.btn:hover { opacity: 0.8; }
.status { text-align: center; font-size: 10px; opacity: 0.7; flex-shrink: 0; }
.loading-state { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; opacity: 0.6; }
.bar { fill: """ + selected['accent'] + """; transition: fill 0.2s, opacity 0.2s; cursor: pointer; }
.bar.selected { fill: """ + selected['accent'] + """; opacity: 1; stroke: #fff; stroke-width: 2px; }
.bar.unselected { fill: rgba(232, 255, 89, 0.3); stroke: none; opacity: 1; }
.bar:hover { opacity: 0.8; }
.axis text { fill: """ + selected['text'] + """; font-size: 12px; font-weight: 500; }
.axis line, .axis path { stroke: """ + selected['text'] + """; opacity: 0.3; }
.tooltip { position: absolute; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 1000; display: none; border: 1px solid #444; }
</style>
</head>
<body>
<div class="tooltip" id="tooltip"></div>
<div class="header">
<div class="title">""" + safe_title + """</div>
</div>
<div class="most-common-crop" id="mostCommonCrop">Loading...</div>
<div class="chart-container" id="chartContainer">
<div id="loadingState" class="loading-state">
<div style="font-size: 24px;">📊</div>
<div style="margin-top: 8px; font-weight: 500;">Loading dataset</div>
</div>
<svg id="histogram" style="display: none; width: 100%; height: 100%;"></svg>
</div>
<div class="selection-info" id="selectionInfo"></div>
<div class="controls">
<div class="controls-row">
<button class="btn" onclick="clearSelection()">Clear Selection</button>
</div>
</div>
<div class="status" id="status">Loading…</div>
<script>
// --- config ---
const componentId = 'histogram-v4-' + Math.random().toString(36).substr(2, 9);
const RECORDS = """ + records_json + """;
const DATASET = """ + repr(dataset) + """;
const VALUE_FIELD = """ + repr(value_field) + """;
const CHANNEL = """ + repr(channel) + """;
// Crop ID to Name mapping
const CROP_NAMES = """ + crop_mapping_js + """;
// --- tiny bus: BroadcastChannel + postMessage fanout ---
let bc = null;
try { if ('BroadcastChannel' in window) bc = new BroadcastChannel(CHANNEL); } catch (e) {}
function busSend(obj) {
const s = JSON.stringify(obj);
try { if (bc) bc.postMessage(obj); } catch(e) {}
try { window.parent.postMessage(s, '*'); } catch(e) {}
try { if (window.top && window.top !== window.parent) window.top.postMessage(s, '*'); } catch(e) {}
try {
if (window.top && window.top.frames) {
for (let i=0; i<window.top.frames.length; i++) {
const f = window.top.frames[i];
if (f !== window) try { f.postMessage(s, '*'); } catch(e) {}
}
}
} catch(e) {}
}
function getFeatureValue(feature, fieldName = VALUE_FIELD) {
if (!feature) return 0;
if (feature[fieldName] !== undefined) {
const direct = Number(feature[fieldName]);
if (Number.isFinite(direct)) return direct;
}
if (feature.properties && feature.properties[fieldName] !== undefined) {
const propVal = Number(feature.properties[fieldName]);
if (Number.isFinite(propVal)) return propVal;
}
return 0;
}
// --- state ---
let fullDataset = [];
let filteredDataset = [];
let activeFilters = new Map();
let topCategories = [];
let svg = null, chartGroup = null, barsGroup = null, xScale = null, yScale = null;
let selectedCategories = new Set();
let chartWidth = 0, chartHeight = 0;
const margin = { top: 10, right: 10, bottom: 120, left: 50 };
// UI handles
const loadingState = document.getElementById('loadingState');
const histogramSvg = document.getElementById('histogram');
const statusElement = document.getElementById('status');
// --- utils ---
function formatValue(val) { return (val >= 1000) ? `${(val/1000).toFixed(1)}k` : `${val.toFixed(0)}`; }
// --- Calculate top 10 categories ---
function getTop10Categories(data) {
if (!data || data.length === 0) return [];
const counts = {};
data.forEach(f => {
const value = getFeatureValue(f);
if (Number.isFinite(value) && value > 0) {
counts[value] = (counts[value] || 0) + 1;
}
});
const entries = Object.entries(counts).map(([id, count]) => ({
id: parseInt(id),
name: CROP_NAMES[parseInt(id)] || `Crop ${id}`,
count: count
}));
entries.sort((a, b) => b.count - a.count);
return entries.slice(0, 10);
}
// --- Calculate most common crop value and map to name ---
function updateMostCommonCrop() {
const el = document.getElementById('mostCommonCrop');
if (!filteredDataset || filteredDataset.length === 0) {
el.textContent = 'No data available';
return;
}
const valueCounts = {};
filteredDataset.forEach(f => {
const value = getFeatureValue(f);
if (Number.isFinite(value) && value > 0) {
valueCounts[value] = (valueCounts[value] || 0) + 1;
}
});
const entries = Object.entries(valueCounts);
if (entries.length === 0) {
el.textContent = 'No valid crop values';
return;
}
entries.sort((a, b) => b[1] - a[1]);
const [mostCommonValue, count] = entries[0];
const cropId = parseInt(mostCommonValue);
const cropName = CROP_NAMES[cropId] || `Unknown Crop (ID: ${cropId})`;
el.textContent = `Most Common: ${cropName} (${formatValue(count)})`;
}
function processRawData(raw) {
if (Array.isArray(raw)) {
return raw.map(item => {
const valueCandidate = item[VALUE_FIELD] ?? item.value ?? item.count ?? item.count_mmsi ?? 0;
const numericValue = Number(valueCandidate);
const resolvedValue = Number.isFinite(numericValue) ? numericValue : 0;
const copy = Object.assign({}, item);
copy[VALUE_FIELD] = resolvedValue;
return copy;
});
}
return [];
}
// --- external filters application ---
function applyAllFilters() {
let filtered = [...fullDataset];
for (let [,f] of activeFilters.entries()) {
const next = applyFilter(filtered, f);
// The map's viewport is broadcast as a spatial filter on every pan/zoom,
// but this dataset is a fixed snapshot, not a live re-fetch -- zooming
// into any sub-area with no matching points would otherwise blank the
// chart with no way back except re-running the UDF. Ignore a spatial
// filter specifically when it would wipe out everything.
if (f.type === 'spatial' && next.length === 0 && filtered.length > 0) continue;
filtered = next;
}
filteredDataset = filtered;
topCategories = getTop10Categories(filteredDataset);
updateHistogram(false);
updateMostCommonCrop();
const summary = `${filtered.length}/${fullDataset.length} features (${activeFilters.size} filters)`;
statusElement.textContent = summary;
}
function applyFilter(data, filter) {
switch (filter.type) {
case 'spatial': return applySpatialFilter(data, filter);
case 'range':
case 'multi-range': return applyRangeFilter(data, filter);
case 'categorical': return applyCategoricalFilter(data, filter);
default: return data;
}
}
function applySpatialFilter(data, filter) {
const b = filter.bounds; if (!b) return data;
return data.filter(f => f.lng>=b.sw.lng && f.lng<=b.ne.lng && f.lat>=b.sw.lat && f.lat<=b.ne.lat);
}
function applyRangeFilter(data, filter) {
const field = filter.field || VALUE_FIELD;
const [min,max] = filter.values || [0, Number.POSITIVE_INFINITY];
return data.filter(f => {
const val = getFeatureValue(f, field);
return val >= min && val <= max;
});
}
function applyCategoricalFilter(data, filter) {
const field = filter.field || VALUE_FIELD;
const values = filter.values || [];
return data.filter(f => {
const val = getFeatureValue(f, field);
return values.includes(val);
});
}
function clearAllFilters() {
activeFilters.clear();
selectedCategories.clear();
filteredDataset = [...fullDataset];
topCategories = getTop10Categories(fullDataset);
updateHistogram();
updateMostCommonCrop();
statusElement.textContent = `${fullDataset.length} features (no filters)`;
busSend({ type:'filter', fromComponent:componentId, timestamp:Date.now(), dataset:DATASET, filter:{ type:'clear', clearAll:true } });
busSend({ type:'filter_cleared', source:componentId, timestamp:Date.now() });
}
// --- UI state ---
function setUIState(state) {
loadingState.style.display = state === 'loading' ? 'flex' : 'none';
histogramSvg.style.display = state === 'chart' ? 'block' : 'none';
}
// --- chart ---
function initializeChart() {
d3.select('#histogram').selectAll('*').remove();
const container = document.getElementById('chartContainer');
const fullWidth = container.clientWidth;
const fullHeight = container.clientHeight;
chartWidth = fullWidth - margin.left - margin.right;
chartHeight = fullHeight - margin.top - margin.bottom;
svg = d3.select('#histogram')
.attr('width', fullWidth)
.attr('height', fullHeight);
chartGroup = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
xScale = d3.scaleBand()
.domain(topCategories.map(d => d.name))
.range([0, chartWidth])
.padding(0.2);
yScale = d3.scaleLinear()
.domain([0, d3.max(topCategories, d => d.count) || 1])
.range([chartHeight, 0])
.nice();
// Add axes
chartGroup.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${chartHeight})`)
.call(d3.axisBottom(xScale))
.selectAll("text")
.style("text-anchor", "end")
.style("fill", """ + repr(selected['accent']) + """)
.style("font-weight", "600")
.style("font-size", "10px")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)");
chartGroup.append('g')
.attr('class', 'y-axis')
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d => formatValue(d)));
barsGroup = chartGroup.append('g').attr('class', 'bars-group');
createBars();
}
function createBars() {
const bars = barsGroup.selectAll('.bar')
.data(topCategories)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.name))
.attr('y', d => yScale(d.count))
.attr('width', xScale.bandwidth())
.attr('height', d => chartHeight - yScale(d.count))
.on('click', function(evt, d) {
evt.stopPropagation();
toggleCategorySelection(d.id);
})
.on('mouseover', function(evt, d) {
if (!selectedCategories.has(d.id)) {
d3.select(this).style('opacity', 0.8);
}
const t = document.getElementById('tooltip');
t.innerHTML = `<div><strong>${d.name}</strong></div><div>Count: ${formatValue(d.count)}</div><div>ID: ${d.id}</div>`;
t.style.display = 'block';
t.style.left = (evt.pageX + 15) + 'px';
t.style.top = (evt.pageY - 10) + 'px';
})
.on('mousemove', function(evt) {
const t = document.getElementById('tooltip');
t.style.left = (evt.pageX + 15) + 'px';
t.style.top = (evt.pageY - 10) + 'px';
})
.on('mouseout', function(evt, d) {
if (!selectedCategories.has(d.id)) {
d3.select(this).style('opacity', 1);
}
document.getElementById('tooltip').style.display = 'none';
});
}
function toggleCategorySelection(cropId) {
if (selectedCategories.has(cropId)) {
selectedCategories.delete(cropId);
} else {
selectedCategories.add(cropId);
}
updateBarSelection();
updateSelectionInfo();
if (selectedCategories.size > 0) {
sendCategoricalFilter(Array.from(selectedCategories));
} else {
clearCategoricalFilter();
}
}
function updateBarSelection() {
if (!barsGroup) return;
barsGroup.selectAll('.bar')
.attr('class', d => {
if (selectedCategories.size === 0) return 'bar';
return selectedCategories.has(d.id) ? 'bar selected' : 'bar unselected';
});
}
function updateHistogram(animate = false) {
const data = filteredDataset;
if (!data.length) {
setUIState('loading');
statusElement.textContent = 'No data matches current filters';
return;
}
topCategories = getTop10Categories(data);
if (!svg) {
initializeChart();
setUIState('chart');
return;
}
// Update scales
xScale.domain(topCategories.map(d => d.name));
yScale.domain([0, d3.max(topCategories, d => d.count) || 1]).nice();
// Update axes
chartGroup.select('.x-axis')
.transition()
.duration(animate ? 300 : 0)
.call(d3.axisBottom(xScale))
.selectAll("text")
.style("text-anchor", "end")
.style("fill", """ + repr(selected['accent']) + """)
.style("font-weight", "600")
.style("font-size", "10px")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)");
chartGroup.select('.y-axis')
.transition()
.duration(animate ? 300 : 0)
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d => formatValue(d)));
// Update bars
const bars = barsGroup.selectAll('.bar').data(topCategories, d => d.id);
// Remove old bars
bars.exit().remove();
// Add new bars
const newBars = bars.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.name))
.attr('y', chartHeight)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.on('click', function(evt, d) {
evt.stopPropagation();
toggleCategorySelection(d.id);
})
.on('mouseover', function(evt, d) {
if (!selectedCategories.has(d.id)) {
d3.select(this).style('opacity', 0.8);
}
const t = document.getElementById('tooltip');
t.innerHTML = `<div><strong>${d.name}</strong></div><div>Count: ${formatValue(d.count)}</div><div>ID: ${d.id}</div>`;
t.style.display = 'block';
t.style.left = (evt.pageX + 15) + 'px';
t.style.top = (evt.pageY - 10) + 'px';
})
.on('mousemove', function(evt) {
const t = document.getElementById('tooltip');
t.style.left = (evt.pageX + 15) + 'px';
t.style.top = (evt.pageY - 10) + 'px';
})
.on('mouseout', function(evt, d) {
if (!selectedCategories.has(d.id)) {
d3.select(this).style('opacity', 1);
}
document.getElementById('tooltip').style.display = 'none';
});
// Update all bars
const allBars = bars.merge(newBars);
if (animate) {
allBars.transition()
.duration(300)
.attr('x', d => xScale(d.name))
.attr('y', d => yScale(d.count))
.attr('width', xScale.bandwidth())
.attr('height', d => chartHeight - yScale(d.count));
} else {
allBars
.attr('x', d => xScale(d.name))
.attr('y', d => yScale(d.count))
.attr('width', xScale.bandwidth())
.attr('height', d => chartHeight - yScale(d.count));
}
updateBarSelection();
setUIState('chart');
}
function updateSelectionInfo() {
const el = document.getElementById('selectionInfo');
if (selectedCategories.size === 0) {
el.textContent = '';
return;
}
const selectedNames = topCategories
.filter(d => selectedCategories.has(d.id))
.map(d => d.name);
const total = topCategories
.filter(d => selectedCategories.has(d.id))
.reduce((sum, d) => sum + d.count, 0);
if (selectedNames.length === 1) {
el.textContent = `Selected: ${selectedNames[0]} (${formatValue(total)} areas)`;
} else {
el.textContent = `Selected: ${selectedNames.length} crops (${formatValue(total)} areas)`;
}
}
// --- selection actions -> send filters to others ---
function sendCategoricalFilter(cropIds) {
if (!cropIds || cropIds.length === 0) return;
const cropNames = cropIds.map(id => {
const cat = topCategories.find(c => c.id === id);
return cat ? cat.name : `Crop ${id}`;
}).join(', ');
statusElement.textContent = `Selected: ${cropNames}`;
// Send unified filter message
busSend({
type: 'filter',
fromComponent: componentId,
timestamp: Date.now(),
dataset: DATASET,
filter: {
type: 'categorical',
field: VALUE_FIELD,
values: cropIds
}
});
}
function clearCategoricalFilter() {
selectedCategories.clear();
updateBarSelection();
updateSelectionInfo();
statusElement.textContent = 'Selection cleared';
// Send unified filter clear
busSend({
type: 'filter',
fromComponent: componentId,
timestamp: Date.now(),
dataset: DATASET,
filter: {
type: 'clear',
field: VALUE_FIELD
}
});
}
function clearSelection() {
clearCategoricalFilter();
}
// --- incoming messages ---
function onMessage(message) {
try {
if (typeof message === 'string') {
try { message = JSON.parse(message); } catch (_) {}
}
if (!message) return;
// Ignore our own messages
if (message.fromComponent && message.fromComponent === componentId) return;
if (message.source && message.source === componentId) return;
// Unified filter handling
if (message.type === 'filter' && message.filter) {
if (message.dataset && DATASET !== 'all' && message.dataset !== DATASET) return;
const f = message.filter;
if (f.type === 'clear') {
if (f.clearAll) activeFilters.clear();
else if (f.field) {
for (const k of Array.from(activeFilters.keys())) {
if (k.includes(`_${f.field}`)) activeFilters.delete(k);
}
} else if (f.filterId) activeFilters.delete(f.filterId);
applyAllFilters();
return;
}
if (f.type === 'spatial' && Array.isArray(f.values)) {
const [w, s, e, n] = f.values;
activeFilters.set(`spatial_${message.fromComponent || 'unknown'}`, {
type: 'spatial',
bounds: { sw: { lng: w, lat: s }, ne: { lng: e, lat: n } }
});
applyAllFilters();
return;
}
if (f.type === 'range' && Array.isArray(f.values)) {
const field = f.field || VALUE_FIELD;
activeFilters.set(`range_${message.fromComponent || 'unknown'}_${field}`, {
type: 'range',
field,
values: f.values
});
applyAllFilters();
return;
}
if (f.type === 'categorical' && Array.isArray(f.values)) {
const field = f.field || VALUE_FIELD;
activeFilters.set(`categorical_${message.fromComponent || 'unknown'}_${field}`, {
type: 'categorical',
field,
values: f.values
});
applyAllFilters();
return;
}
}
// Legacy filter handling
if (message.type === 'spatial_filter' && message.bounds) {
activeFilters.set(`spatial_legacy_${message.source || 'unknown'}`, {
type: 'spatial',
bounds: message.bounds
});
applyAllFilters();
return;
}
if (message.type === 'range_filter' && typeof message.min !== 'undefined' && typeof message.max !== 'undefined') {
const field = message.field || VALUE_FIELD;
activeFilters.set(`range_legacy_${message.source || 'unknown'}_${field}`, {
type: 'range',
field,
values: [+message.min, +message.max]
});
applyAllFilters();
return;
}
if (message.type === 'filter_cleared') {
if (message.field) {
for (const k of Array.from(activeFilters.keys())) {
if (k.includes(`_${message.field}`)) activeFilters.delete(k);
}
} else if (message.source) {
for (const k of Array.from(activeFilters.keys())) {
if (k.includes(`_${message.source}`)) activeFilters.delete(k);
}
} else {
activeFilters.clear();
}
applyAllFilters();
return;
}
} catch (e) {
console.error('Error handling message:', e);
}
}
if (bc) bc.onmessage = (ev) => onMessage(ev.data);
window.addEventListener('message', (ev) => onMessage(ev.data));
// --- init: data is embedded, so this is synchronous, no network/WASM involved ---
try {
fullDataset = processRawData(RECORDS);
topCategories = getTop10Categories(fullDataset);
applyAllFilters();
} catch (e) {
console.error(e);
statusElement.textContent = `Init failed: ${e && e.message ? e.message : e}`;
}
setTimeout(() => {
busSend({
type: 'component_ready',
componentType: 'categorical_chart',
componentId: componentId,
capabilities: ['filter'],
dataSource: 'independent',
protocol: 'unified'
});
}, 300);
</script>
</body>
</html>
"""
return common.html_to_obj(html_content)
Map UDF
The Map UDF renders the geospatial hexagons and supports interactions with the histogram or bar chart to update map highlights in real time.
# doctest: skip
common = fused.load("https://github.com/fusedio/udfs/tree/b6786d6/public/common/")
DEFAULT_CONFIG = r"""{
"tileLayer": {
"@@type": "TileLayer",
"maxZoom": 12
},
"hexLayer": {
"@@type": "H3HexagonLayer",
"filled": true,
"pickable": true,
"extruded": false,
"getHexagon": "@@=properties.hex",
"getFillColor": {
"@@function": "colorContinuous",
"attr": "elevation",
"domain": [0, 3000],
"steps": 20,
"colors": "OrYel"
}
}
}"""
DEFAULT_STYLE_URL = "mapbox://styles/mapbox/dark-v11"
# Pairs with join_era5_cdl_elevation.py -- bump this note if that UDF's schema changes,
# so this UDF's own cached result gets invalidated and re-executed too.
@fused.udf
def udf(
bounds: list = [-125.0, 32.0, -114.0, 42.0],
res: int = 5,
year: int = 2024,
month: str = "2024-05",
config_json: str = DEFAULT_CONFIG,
mapbox_token: str = "pk.eyJ1IjoiaXNhYWNmdXNlZGxhYnMiLCJhIjoiY2xicGdwdHljMHQ1bzN4cWhtNThvbzdqcSJ9.73fb6zHMeO_c8eAXpZVNrA",
center_lng: float = -119.4179,
center_lat: float = 36.7783,
zoom: float = 4,
tooltip_columns: list = ["crop_rank_1", "elevation", "daily_mean"],
min_elevation: float = 0,
map_style_url: str = DEFAULT_STYLE_URL,
channel: str = "fused-elevation-temp",
dataset: str = "all",
):
from jinja2 import Template
style_url = map_style_url or DEFAULT_STYLE_URL
# Compute the joined dataset server-side and embed it directly in the page as
# plain JSON -- canvas node iframes run with an opaque origin, so a client-side
# fetch() back to another node's URL is blocked by CORS with no way to allow it
# from here. The dataset is small (a few thousand rows), so there's no need for
# a client-side SQL engine either -- plain JS array filtering is enough and has
# far fewer moving parts (no WASM, no Worker, no CORS, no Parquet compatibility
# surface) to fail silently inside a sandboxed iframe.
join_era5_cdl_elevation = fused.load("join_era5_cdl_elevation")
df = join_era5_cdl_elevation(bounds=bounds, res=res, year=year, month=month)
df = df.copy()
df["hex"] = df["hex"].astype(str) # preserve full 64-bit H3 id precision through JSON (JS numbers only hold 53 bits)
records_json = df.to_json(orient="records")
html = Template(r"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>H3 Hexagon Viewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js"></script>
<script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>
<script src="https://unpkg.com/deck.gl@9.1.3/dist.min.js"></script>
<script src="https://unpkg.com/@deck.gl/geo-layers@9.1.3/dist.min.js"></script>
<script src="https://unpkg.com/@deck.gl/carto@9.1.3/dist.min.js"></script>
<style>
:root {
--bg-panel: rgba(15,15,15,0.9);
--bg-panel-solid: #0f0f0f;
--text-primary: #ffffff;
--text-dim: #9e9e9e;
--radius-md: 6px;
--shadow-soft: 0 -4px 12px rgba(0,0,0,.7);
}
html, body {
margin:0; padding:0; height:100%;
background:#000;
color:var(--text-primary);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#map { position:fixed; inset:0; }
#tooltip {
position: absolute;
pointer-events: none;
background: rgba(0,0,0,0.8);
color: var(--text-primary);
padding: 4px 8px;
border-radius: var(--radius-md);
font-size: 12px;
display: none;
z-index: 6;
white-space: nowrap;
border:1px solid #3a3a3a;
}
.status-pill {
position:absolute;
top:12px;
left:12px;
background:var(--bg-panel);
color:var(--text-primary);
padding:6px 10px;
border-radius:var(--radius-md);
border:1px solid #3a3a3a;
font-size:11px;
z-index:7;
pointer-events:none;
box-shadow:var(--shadow-soft);
min-width:120px;
text-align:center;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="tooltip"></div>
<div class="status-pill" id="statusPill">initializing…</div>
<script type="module">
// --- UDF PARAMS ---------------------------------------------------------
const RECORDS = {{ records_json | safe }};
const MAPBOX_TOKEN = {{ mapbox_token | tojson }};
const MAP_STYLE_URL = {{ map_style_url | tojson }};
const DEFAULT_STYLE_URL = {{ default_style_url | tojson }};
const START_CENTER = [{{ center_lng }}, {{ center_lat }}];
const START_ZOOM = {{ zoom }};
const TOOLTIP_COLUMNS = {{ tooltip_columns | tojson }};
const MIN_ELEVATION = {{ min_elevation }};
const CHANNEL = {{ channel | tojson }};
const DATASET = {{ dataset | tojson }};
const componentId = 'duckmap-' + Math.random().toString(36).slice(2);
const INITIAL_CONFIG = {{ config_json | tojson }};
// deck.gl bits
const { MapboxOverlay, PolygonLayer } = deck;
const H3HexagonLayer = deck.H3HexagonLayer || (deck.GeoLayers && deck.GeoLayers.H3HexagonLayer);
const { colorContinuous } = deck.carto;
// state
let overlay;
let map;
let didInitialFit = false;
let currentConfObj = {};
let fullDataset = [];
let filteredDataset = [];
let activeFilters = new Map();
function $(id){ return document.getElementById(id); }
const statusPill = $("statusPill");
if (statusPill) {
statusPill.textContent = "initializing…";
}
let bc = null;
try {
if ('BroadcastChannel' in window) bc = new BroadcastChannel(CHANNEL);
} catch (_) { /* ignore */ }
function updateStatus(text) {
if (statusPill) statusPill.textContent = text;
}
function busSend(obj) {
const payload = JSON.stringify(obj);
try { if (bc) bc.postMessage(obj); } catch (e) {}
try { window.parent.postMessage(payload, '*'); } catch (e) {}
try { if (window.top && window.top !== window.parent) window.top.postMessage(payload, '*'); } catch (e) {}
try {
if (window.top && window.top.frames) {
for (let i = 0; i < window.top.frames.length; i++) {
const frame = window.top.frames[i];
if (frame !== window) {
try { frame.postMessage(payload, '*'); } catch (e) {}
}
}
}
} catch (e) {}
}
function getFeatureValue(feature, fieldName) {
if (!feature) return 0;
const direct = Number(feature[fieldName]);
if (Number.isFinite(direct)) return direct;
return 0;
}
function cloneConfig(conf) {
if (!conf || typeof conf !== 'object') return {};
if (typeof structuredClone === 'function') {
try { return structuredClone(conf); } catch (_) {}
}
try {
return JSON.parse(JSON.stringify(conf));
} catch (err) {
console.error('config clone error', err);
return {};
}
}
function sanitizeFieldName(field) {
if (typeof field !== 'string') return null;
const trimmed = field.trim();
if (!trimmed) return null;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return null;
return trimmed;
}
function applyIncomingStyle(style) {
if (!style) return;
const attr = sanitizeFieldName(style.attr || 'metric');
if (!attr) return;
const domain = Array.isArray(style.domain) ? style.domain.map(Number) : [];
if (domain.length !== 2 || !domain.every(Number.isFinite)) return;
const steps = Number(style.steps);
const validSteps = Number.isInteger(steps) && steps > 0 ? steps : 20;
const colors = typeof style.colors === 'string' && style.colors.trim() ? style.colors.trim() : 'OrYel';
const rawConf = cloneConfig(currentConfObj);
rawConf.hexLayer = rawConf.hexLayer || {};
rawConf.hexLayer.getFillColor = {
"@@function": "colorContinuous",
"attr": attr,
"domain": [domain[0], domain[1]],
"steps": validSteps,
"colors": colors,
"nullColor": rawConf.hexLayer?.getFillColor?.nullColor || [184, 184, 184]
};
currentConfObj = rawConf;
updateStatus(`style ${attr} (${validSteps} steps)`);
renderLayer();
}
function rafThrottle(fn) {
let ticking = false;
let lastArgs = null;
return (...args) => {
lastArgs = args;
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
fn(...(lastArgs || []));
});
};
}
let suppressSpatialEmits = 0;
// --- filtering (same model as the histogram/bar-chart panels) ----------
function applyFilter(data, filter) {
switch (filter.type) {
case 'spatial': return applySpatialFilter(data, filter);
case 'range':
case 'multi-range': return applyRangeFilter(data, filter);
case 'categorical': return applyCategoricalFilter(data, filter);
default: return data;
}
}
function applySpatialFilter(data, filter) {
const b = filter.bounds; if (!b) return data;
return data.filter(f => f.lng>=b.sw.lng && f.lng<=b.ne.lng && f.lat>=b.sw.lat && f.lat<=b.ne.lat);
}
function applyRangeFilter(data, filter) {
const field = filter.field;
const [min, max] = filter.values || [0, Number.POSITIVE_INFINITY];
return data.filter(f => {
const val = getFeatureValue(f, field);
return val >= min && val <= max;
});
}
function applyCategoricalFilter(data, filter) {
const field = filter.field;
const values = filter.values || [];
return data.filter(f => values.includes(getFeatureValue(f, field)));
}
function applyAllFilters() {
let filtered = fullDataset.filter(f => getFeatureValue(f, 'elevation') > MIN_ELEVATION);
for (const [, f] of activeFilters.entries()) filtered = applyFilter(filtered, f);
filteredDataset = filtered;
renderLayer();
updateStatus(`rendered ${filteredDataset.length.toLocaleString()} features`);
}
// --- tiny helpers -------------------------------------------------------
function evalExpression(expr, object) {
if (typeof expr === 'string' && expr.startsWith('@@=')) {
const code = expr.slice(3);
try {
const fn = new Function('object', `
const properties = object?.properties || object || {};
return (${code});
`);
return fn(object);
} catch (e) { console.error('@@= eval error:', expr, e); return null; }
}
return expr;
}
function hasProp({ property, present, absent }) {
return (object) => {
const props = object?.properties || object || {};
if (property in props && props[property] !== null && props[property] !== undefined) {
if (typeof present === 'function') return present(object);
if (typeof present === 'string' && present.startsWith('@@=')) return evalExpression(present, object);
return present;
}
return typeof absent === 'function' ? absent(object) : absent;
};
}
function processColorContinuous(cfg) {
let domain = cfg.domain;
if (domain && domain.length === 2) {
const start = Number(domain[0]);
const end = Number(domain[1]);
const steps = cfg.steps ?? 20;
if (Number.isFinite(start) && Number.isFinite(end)) {
if (steps > 1) {
const stepSize = (end - start) / (steps - 1);
domain = Array.from({ length: steps }, (_, i) => start + stepSize * i);
} else {
domain = [start];
}
} else {
domain = [domain[0], domain[1]];
}
}
return {
attr: cfg.attr,
domain: domain,
colors: cfg.colors || 'TealGrn',
nullColor: cfg.nullColor || [184,184,184]
};
}
function parseHexLayerConfig(config) {
const out = {};
for (const [k, v] of Object.entries(config || {})) {
if (k === '@@type') continue;
if (v && typeof v === 'object' && !Array.isArray(v)) {
if (v['@@function'] === 'colorContinuous') {
out[k] = colorContinuous(processColorContinuous(v));
} else if (v['@@function'] === 'hasProp') {
out[k] = hasProp(v);
} else {
out[k] = v;
}
} else if (typeof v === 'string' && v.startsWith('@@=')) {
out[k] = (obj) => evalExpression(v, obj);
} else {
out[k] = v;
}
}
return out;
}
function resolveStyleUrl(styleUrl, token) {
const trimmed = (styleUrl || '').trim();
if (!trimmed) return DEFAULT_STYLE_URL;
if (trimmed.startsWith('mapbox://')) {
if (!token) {
console.warn('Mapbox style requested but mapbox_token parameter is missing. Falling back to default style.');
return DEFAULT_STYLE_URL;
}
const stylePath = trimmed.replace('mapbox://styles/', '');
return `https://api.mapbox.com/styles/v1/${stylePath}?access_token=${token}`;
}
if (token && trimmed.includes('{token}')) {
return trimmed.replace('{token}', token);
}
return trimmed;
}
function rowsToFeatures(rows, confObj){
const colorAttr = confObj?.hexLayer?.getFillColor?.attr || "metric";
return rows.map(p => {
const hex = p.hex ?? p.h3 ?? p.index ?? p.id;
if (!hex || typeof hex !== 'string') return null;
const obj = { ...p };
if (obj[colorAttr] !== undefined) {
const n = Number(obj[colorAttr]);
obj[colorAttr] = Number.isFinite(n) ? n : null;
}
(TOOLTIP_COLUMNS || []).forEach(col => {
if (obj[col] !== undefined && typeof obj[col] !== "number") {
const nn = Number(obj[col]);
if (Number.isFinite(nn)) obj[col] = nn;
}
});
return {
...obj,
hex,
properties: { ...obj, hex }
};
}).filter(Boolean);
}
function fitToDataOnce(data){
if (didInitialFit || !data.length) return;
let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
for (const d of data){
const [lat,lng] = h3.cellToLatLng(d.hex);
if (lng<minX)minX=lng;
if (lat<minY)minY=lat;
if (lng>maxX)maxX=lng;
if (lat>maxY)maxY=lat;
}
if (minX<Infinity) {
map.fitBounds([[minX,minY],[maxX,maxY]], {padding:20,duration:0});
didInitialFit = true;
}
}
function applyIncomingSpatial(bounds, options = {}) {
if (!map || !bounds) return;
const sw = bounds.sw || bounds.southWest;
const ne = bounds.ne || bounds.northEast;
if (!sw || !ne) return;
const west = Number(sw.lng ?? sw.lon ?? sw.longitude);
const south = Number(sw.lat ?? sw.latitude);
const east = Number(ne.lng ?? ne.lon ?? ne.longitude);
const north = Number(ne.lat ?? ne.latitude);
if (![west, south, east, north].every(Number.isFinite)) return;
suppressSpatialEmits = Math.max(suppressSpatialEmits, options.suppressEmits ?? 2);
try {
map.fitBounds([[west, south], [east, north]], {
padding: options.padding ?? 32,
duration: options.duration ?? 0
});
updateStatus('applied incoming spatial');
} catch (err) {
console.warn('applyIncomingSpatial error', err);
}
}
function postSpatial(){
if (!map) return;
if (suppressSpatialEmits > 0) {
suppressSpatialEmits -= 1;
return;
}
try {
const b = map.getBounds?.();
if (!b) return;
const west = b.getWest();
const south = b.getSouth();
const east = b.getEast();
const north = b.getNorth();
if (![west, south, east, north].every(Number.isFinite)) return;
const legacy = {
type: 'spatial_filter',
bounds: {
sw: { lng: west, lat: south },
ne: { lng: east, lat: north }
},
timestamp: Date.now(),
source: componentId
};
const unified = {
type: 'filter',
fromComponent: componentId,
timestamp: Date.now(),
dataset: DATASET,
filter: {
type: 'spatial',
fields: ['lng','lat'],
values: [west, south, east, north]
}
};
busSend(legacy);
busSend(unified);
updateStatus(`sent spatial (z=${map.getZoom().toFixed(1)})`);
} catch (err) {
console.error('postSpatial error', err);
}
}
function onMessage(msg) {
if (typeof msg === 'string') {
try {
msg = JSON.parse(msg);
} catch (_) {
// keep original string if not JSON
}
}
if (!msg || typeof msg !== 'object') return;
if ((msg.fromComponent && msg.fromComponent === componentId) || (msg.source && msg.source === componentId)) {
return;
}
const datasetOk = (!msg.dataset) || DATASET === 'all' || msg.dataset === DATASET;
if (msg.type === 'duckmap_style' && datasetOk && msg.style && msg.fromComponent !== componentId) {
applyIncomingStyle(msg.style);
return;
}
if (msg.type === 'filter' && msg.filter && datasetOk) {
const f = msg.filter;
if (f.type === 'clear') {
if (f.clearAll) activeFilters.clear();
else if (f.field) {
for (const k of Array.from(activeFilters.keys())) if (k.includes(`_${f.field}`)) activeFilters.delete(k);
} else if (f.filterId) activeFilters.delete(f.filterId);
applyAllFilters();
updateStatus('incoming filter cleared');
return;
}
if (f.type === 'spatial' && Array.isArray(f.values)) {
const [w, s, e, n] = f.values.map(Number);
if ([w, s, e, n].every(Number.isFinite)) {
applyIncomingSpatial({
sw: { lng: w, lat: s },
ne: { lng: e, lat: n }
}, { padding: 40, duration: 0, suppressEmits: 4 });
}
return;
}
if (f.type === 'range' && Array.isArray(f.values)) {
const field = sanitizeFieldName(f.field || 'value');
if (!field) return;
activeFilters.set(`range_${msg.fromComponent||'unknown'}_${field}`, { type: 'range', field, values: f.values });
applyAllFilters();
updateStatus(`incoming range ${field}`);
return;
}
if (f.type === 'categorical' && Array.isArray(f.values)) {
const field = sanitizeFieldName(f.field || 'value');
if (!field) return;
activeFilters.set(`categorical_${msg.fromComponent||'unknown'}_${field}`, { type: 'categorical', field, values: f.values });
applyAllFilters();
updateStatus(`incoming categorical ${field}`);
return;
}
return;
}
if (msg.type === 'spatial_filter' && msg.bounds) {
applyIncomingSpatial(msg.bounds, { padding: 40, duration: 0, suppressEmits: 4 });
return;
}
if (msg.type === 'filter_cleared' && datasetOk) {
activeFilters.clear();
applyAllFilters();
updateStatus('incoming filter cleared');
}
}
if (bc) bc.onmessage = (ev) => onMessage(ev.data);
window.addEventListener('message', (ev) => onMessage(ev.data));
function buildDeckLayer(data, confObj) {
const hexCfg = parseHexLayerConfig(confObj.hexLayer || {});
return H3HexagonLayer ? new H3HexagonLayer({
id: 'hex-layer',
data,
pickable: true,
stroked: false,
filled: true,
extruded: !!confObj.hexLayer?.extruded,
coverage: 0.9,
lineWidthMinPixels: 1,
getHexagon: d => d.hex,
...hexCfg
}) : new PolygonLayer({
id: 'hex-layer-fallback',
data: data.map(d => {
const ring = h3.cellToBoundary(d.hex, true).map(([lat,lng]) => [lng, lat]);
return { ...d, polygon: ring };
}),
pickable: true,
stroked: true,
filled: true,
extruded: false,
getPolygon: d => d.polygon,
getFillColor: [184,184,184,220],
getLineColor: [200,200,200,255],
lineWidthMinPixels: 1
});
}
function handleHoverFactory(confObj){
const colorAttr = confObj?.hexLayer?.getFillColor?.attr || "metric";
return function handleHover(info){
const tooltipEl = $("tooltip");
if(info?.object){
map.getCanvas().style.cursor='pointer';
const p = info.object;
const vals = [];
if (TOOLTIP_COLUMNS && TOOLTIP_COLUMNS.length){
TOOLTIP_COLUMNS.forEach(col=>{
if (p[col] !== undefined){
const v = p[col];
if (typeof v === "number" && Number.isFinite(v)) {
vals.push(col+": "+v.toFixed(2));
} else {
vals.push(col+": "+v);
}
}
});
} else {
const v = p[colorAttr];
if (v != null) {
if (typeof v === "number" && Number.isFinite(v)) {
vals.push(colorAttr+": "+v.toFixed(2));
} else {
vals.push(colorAttr+": "+v);
}
}
}
vals.unshift("hex "+p.hex);
const text = vals.join(" • ");
tooltipEl.innerHTML = text;
tooltipEl.style.left = (info.x+10)+"px";
tooltipEl.style.top = (info.y+10)+"px";
tooltipEl.style.display='block';
} else {
map.getCanvas().style.cursor='';
tooltipEl.style.display='none';
}
};
}
function updateOverlayLayers(data, confObj){
const layer = buildDeckLayer(data, confObj);
const onHover = handleHoverFactory(confObj);
if (!overlay) {
overlay = new MapboxOverlay({
interleaved: false,
layers: [layer],
onHover
});
map.addControl(overlay);
} else {
overlay.setProps({
layers: [layer],
onHover
});
}
}
function renderLayer() {
if (!map) return;
const data = rowsToFeatures(filteredDataset, currentConfObj);
fitToDataOnce(data);
updateOverlayLayers(data, currentConfObj);
}
// --- map init -------------------------------------------------------
async function initMap(){
updateStatus('initializing map…');
const style = resolveStyleUrl(MAP_STYLE_URL, MAPBOX_TOKEN);
mapboxgl.accessToken = MAPBOX_TOKEN;
map = new mapboxgl.Map({
container:'map',
style,
center: START_CENTER,
zoom: START_ZOOM,
projection: 'mercator',
dragRotate:false,
pitchWithRotate:false
});
await new Promise(resolve => {
map.once('load', () => {
updateStatus('map ready');
postSpatial();
resolve();
});
});
const sendSpatial = rafThrottle(postSpatial);
const spatialEvents = ['move','drag','zoom','moveend','zoomend','pitch','rotate'];
spatialEvents.forEach(ev => map.on(ev, sendSpatial));
}
// --- main boot ----------------------------------------------------------
async function main(){
try {
currentConfObj = JSON.parse(INITIAL_CONFIG || "{}");
} catch (_) {
currentConfObj = {};
}
if (!currentConfObj || typeof currentConfObj !== 'object') {
currentConfObj = {};
}
// data is embedded, so this is synchronous, no network/WASM involved
fullDataset = RECORDS;
await initMap();
applyAllFilters();
}
main().catch(e=>{
console.error("init error", e);
updateStatus('init error: ' + (e && e.message ? e.message : e));
});
// block pinch-zoom/ctrl+wheel zoom
document.addEventListener('wheel', function(e){
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
}
}, { passive: false });
document.addEventListener('touchstart', function(e){
if (e.touches && e.touches.length > 1) {
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchmove', function(e){
if (e.touches && e.touches.length > 1) {
e.preventDefault();
}
}, { passive: false });
</script>
</body>
</html>
""").render(
records_json=records_json,
config_json=config_json,
mapbox_token=mapbox_token,
map_style_url=style_url,
default_style_url=DEFAULT_STYLE_URL,
center_lng=center_lng,
center_lat=center_lat,
zoom=zoom,
tooltip_columns=tooltip_columns,
min_elevation=min_elevation,
channel=channel,
dataset=dataset,
)
return common.html_to_obj(html)
In Workbench Map Viewer
Quick styling options for H3 data in Workbench:
Feel free to directly reach out to us at info@fused.io if you have any questions on implementing this yourself