Skip to content

Basic Scatter Plot

A scatter plot of the classic Iris dataset — sepal length vs. petal length coloured by species. The wrapper uses type: 'cartesian' to build a dual-dimension navigation structure: navigate across sepal-length bins with ← →, navigate across petal-length bins with ↑ ↓. At the deepest level all four arrow keys stay active so users can roam freely across both axes.

Live example

Structure

For type: 'cartesian' the navigation hierarchy has two independent numerical dimensions that share the same leaf nodes. Each numerical dimension is automatically divided into bins:

chart root
  ├─ sepal_length dimension  (← →)
  │    ├─ bin 1  [~4.7 – 5.3]
  │    │    ├─ s3: sepal 4.7, petal 1.3   ← leaf (all four arrow keys active)
  │    │    ├─ s2: sepal 4.9, petal 1.4
  │    │    └─ ... (setosa points)
  │    ├─ bin 2  [~5.3 – 5.9]
  │    │    └─ ...
  │    └─ ... (bins continue to max sepal length)
  └─ petal_length dimension  (↑ ↓)
       ├─ bin 1  [~1.0 – 2.25]
       │    └─ ... (same leaf nodes, reached via a different path)
       └─ ...
Location← →↑ ↓EnterWJBackspace
Chart rootGo to sepal_length dimension
sepal_length dimension rootGo to petal_length dimensionGo to first binChart root
sepal_length binPrevious / next binGo to first leafsepal_length dimension
petal_length dimension rootGo to sepal_length dimensionGo to first binChart root
petal_length binPrevious / next binGo to first leafpetal_length dimension
LeafPrevious / next sepal binPrevious / next petal binParent sepal binParent petal bin
  • The bin count is derived automatically as ceil(sqrt(N)) (minimum 3), where N is the number of data points.
  • Bins are sorted in ascending order and use terminal extents — navigation stops at the first / last bin rather than wrapping.
  • Use idField to specify which data field uniquely identifies each point; without it the wrapper generates sequential IDs.

Full code

Create three files in the same directory and serve them with a local server (e.g. npx serve . or python -m http.server). Bokeh is loaded from CDN; the wrapper is loaded via import map. The wrapper tab is the integration layer; chart is the Bokeh rendering code.

js
import { addDataNavigator } from '@data-navigator/bokeh-wrapper';
import { data, globalXMin, globalXMax, globalYMin, globalYMax, drawChart } from './chart.js';

let wrapper = null;
let rects = [];
let points = [];
let divisionRectsByDimension = {};

function buildDivisionRects() {
    divisionRectsByDimension = {};
    if (!wrapper) return;
    for (const node of Object.values(wrapper.structure.nodes)) {
        if (node.dimensionLevel === 2 && node.data?.numericalExtents) {
            const dimKey = node.derivedNode;
            const [lo, hi] = node.data.numericalExtents;
            if (!divisionRectsByDimension[dimKey]) divisionRectsByDimension[dimKey] = [];
            if (dimKey === 'sepal_length') {
                divisionRectsByDimension[dimKey].push({ x1: lo, x2: hi, y1: globalYMin, y2: globalYMax, lineWidth: 1 });
            } else {
                divisionRectsByDimension[dimKey].push({ x1: globalXMin, x2: globalXMax, y1: lo, y2: hi, lineWidth: 1 });
            }
        }
    }
}

function initWrapper(mode) {
    wrapper?.destroy();
    rects = [];
    points = [];
    divisionRectsByDimension = {};
    drawChart({ rects, points });
    wrapper = addDataNavigator({
        plotContainer: 'scatter-plot',
        chatContainer: 'scatter-chat',
        mode,
        data,
        type: 'cartesian',
        xField: 'sepal_length',
        yField: 'petal_length',
        idField: 'pt',
        title: 'Iris: sepal length vs petal length',
        onNavigate(node) {
            const level = node.dimensionLevel;
            if (level === 0) {
                // Chart root — show all bins from both dimensions at once
                points = [];
                rects = Object.values(divisionRectsByDimension).flat();
            } else if (level === 1) {
                // Dimension root — show every bin of this dimension
                points = [];
                const dimKey = node.data?.dimensionKey;
                rects = divisionRectsByDimension[dimKey] ?? [];
            } else if (node.derivedNode) {
                // Division node — exact bin boundary, 2px stroke
                points = [];
                const [lo, hi] = node.data?.numericalExtents ?? [0, 0];
                if (node.derivedNode === 'sepal_length') {
                    rects = [{ x1: lo, x2: hi, y1: globalYMin, y2: globalYMax, lineWidth: 2 }];
                } else {
                    rects = [{ x1: globalXMin, x2: globalXMax, y1: lo, y2: hi, lineWidth: 2 }];
                }
            } else {
                // Leaf node — individual point indicator
                rects = [];
                points = [{ x: +node.data.sepal_length, y: +node.data.petal_length }];
            }
            drawChart({ rects, points });
        },
        onExit() {
            rects = [];
            points = [];
            drawChart({ rects, points });
        }
    });
    buildDivisionRects();
}

initWrapper('text');

document.getElementById('scatter-keyboard')?.addEventListener('change', e => {
    initWrapper(e.target.checked ? 'keyboard' : 'text');
});
js
export const data = [
    { pt: 's1', sepal_length: 5.1, petal_length: 1.4, species: 'setosa' },
    { pt: 's2', sepal_length: 4.9, petal_length: 1.4, species: 'setosa' },
    { pt: 's3', sepal_length: 4.7, petal_length: 1.3, species: 'setosa' },
    { pt: 's4', sepal_length: 5.8, petal_length: 1.2, species: 'setosa' },
    { pt: 's5', sepal_length: 5.0, petal_length: 1.0, species: 'setosa' },
    { pt: 'v1', sepal_length: 7.0, petal_length: 4.7, species: 'versicolor' },
    { pt: 'v2', sepal_length: 6.4, petal_length: 4.5, species: 'versicolor' },
    { pt: 'v3', sepal_length: 6.9, petal_length: 4.9, species: 'versicolor' },
    { pt: 'v4', sepal_length: 5.5, petal_length: 4.0, species: 'versicolor' },
    { pt: 'v5', sepal_length: 6.5, petal_length: 4.6, species: 'versicolor' },
    { pt: 'g1', sepal_length: 6.3, petal_length: 6.0, species: 'virginica' },
    { pt: 'g2', sepal_length: 5.8, petal_length: 5.1, species: 'virginica' },
    { pt: 'g3', sepal_length: 7.1, petal_length: 5.9, species: 'virginica' },
    { pt: 'g4', sepal_length: 6.3, petal_length: 5.6, species: 'virginica' },
    { pt: 'g5', sepal_length: 6.5, petal_length: 5.8, species: 'virginica' }
];

export const colors = { setosa: '#e41a1c', versicolor: '#377eb8', virginica: '#4daf4a' };

export const globalXMin = Math.min(...data.map(d => d.sepal_length));
export const globalXMax = Math.max(...data.map(d => d.sepal_length));
export const globalYMin = Math.min(...data.map(d => d.petal_length));
export const globalYMax = Math.max(...data.map(d => d.petal_length));

export function drawChart({ rects = [], points = [] } = {}) {
    const container = document.getElementById('scatter-chart-inner');
    container.innerHTML = '';
    const plt = Bokeh.Plotting;
    const p = plt.figure({
        height: 320,
        width: 480,
        title: 'Iris: sepal length vs petal length',
        x_axis_label: 'Sepal length (cm)',
        y_axis_label: 'Petal length (cm)',
        toolbar_location: null,
        output_backend: 'svg'
    });

    // Focus rectangles (stroke only, no fill) drawn behind the data points
    for (const rect of rects) {
        p.quad({
            left: [rect.x1],
            right: [rect.x2],
            bottom: [rect.y1],
            top: [rect.y2],
            fill_alpha: 0,
            line_color: '#333',
            line_width: rect.lineWidth
        });
    }

    // All scatter points rendered identically — no dimming or size changes
    data.forEach(d => {
        p.scatter({
            marker: 'circle',
            x: [d.sepal_length],
            y: [d.petal_length],
            size: 8,
            fill_color: colors[d.species],
            line_color: colors[d.species],
            line_width: 1,
            fill_alpha: 0.7
        });
    });

    // Focus indicator over individual points at leaf level
    for (const point of points) {
        p.scatter({
            marker: 'circle',
            x: [point.x],
            y: [point.y],
            size: 8,
            fill_color: '#333',
            line_color: '#333',
            line_width: 2,
            fill_alpha: 0
        });
    }

    plt.show(p, '#scatter-chart-inner');
}
html
<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Scatter Plot — Data Navigator Bokeh Wrapper</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/data-navigator/text-chat.css" />
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.3.min.js" crossorigin="anonymous"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.3.min.js" crossorigin="anonymous"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.3.min.js" crossorigin="anonymous"></script>
        <script src="https://cdn.bokeh.org/bokeh/release/bokeh-api-3.7.3.min.js" crossorigin="anonymous"></script>
        <script type="importmap">
            {
                "imports": {
                    "@data-navigator/bokeh-wrapper": "https://esm.sh/@data-navigator/bokeh-wrapper",
                    "data-navigator": "https://esm.sh/data-navigator"
                }
            }
        </script>
    </head>
    <body>
        <div id="scatter-plot" style="display:inline-block;">
            <div id="scatter-chart-inner"></div>
        </div>
        <label>
            <input type="checkbox" id="scatter-keyboard" />
            Use keyboard navigation
        </label>
        <div id="scatter-chat" style="max-width:500px;"></div>
        <script type="module" src="./wrapper.js"></script>
    </body>
</html>

Released under the MIT License.