table
A data table as a Widget3d: a header that stays put, a virtualized scrolling
body, and optional selection drawn with state icons. Drops into a
widget-box / surface panel like any other control, and renders identically flat
and rasterized onto a plane.
The arithmetic is table-layout — column resolution and the visible-row window — so this file is paint plus interaction.
Virtualized by default
Only the rows in view are built. That matters more on a texture than in the DOM,
because unseen rows are paid for twice: once building SVG nodes, and again
rasterizing the whole sheet to a DynamicTexture on every change. A 500-row inventory
would otherwise be 500 <g>s in a texture showing twelve.
Demo
Scroll the body (wheel or drag). Click a row to select it — the state icon is a different visual channel from hover, so a row can be hovered and selected and both still read.
import { surface, widgetBox, box, textBlock, table, svgPoint } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'
const { svg } = svgElements
const { div } = elements
const W = 400
const H = 300
const KINDS = ['rifle', 'medkit', 'ration', 'battery', 'flare', 'rope', 'lens']
const rows = Array.from({ length: 200 }, (_, i) => ({
id: 'r' + i,
name: KINDS[i % KINDS.length] + ' ' + (i + 1),
qty: ((i * 7) % 40) + 1,
mass: (((i * 13) % 90) / 10 + 0.4).toFixed(1),
}))
const readout = div({ style: 'margin:8px 16px;color:#8ea;font:13px system-ui' }, 'No selection.')
const t = table({
rows,
columns: [
{ key: 'name', label: 'Item', flex: 1 },
{ key: 'qty', label: 'Qty', width: 54, align: 'right' },
{ key: 'mass', label: 'kg', width: 54, align: 'right' },
],
height: 190,
selection: 'multi',
onSelect: (ids) => {
readout.textContent = ids.length ? `Selected ${ids.length}: ${ids.slice(0, 4).join(', ')}${ids.length > 4 ? '…' : ''}` : 'No selection.'
},
})
const s = surface({ width: W, height: H })
s.setContent(box({ width: W, height: H, padding: 10, gap: 8, background: '#12151c' },
textBlock('Inventory — 200 rows, a dozen drawn', { font: { size: 14, weight: 600 }, color: '#e6e6e6' })))
s.openPanel({ x: 8, y: 46 }, widgetBox({ width: 376, padding: 8, background: '#0e1116' }, [t]),
{ title: 'Cargo', draggable: true })
// Scale to whatever room we're given: a fixed viewBox with width/height 100% means
// maximizing the example fills the space instead of leaving the panel marooned in a
// corner. Pointer coords go through svgPoint, which handles the letterboxing.
const svgEl = svg({
viewBox: `0 0 ${W} ${H}`,
width: '100%',
height: '100%',
preserveAspectRatio: 'xMidYMid meet',
style: 'display:block;touch-action:none',
}, s.el)
const at = (e) => {
// svgPoint, not rect arithmetic: the viewBox is letterboxed when the
// container's aspect ratio differs, and a linear map drifts as it's resized.
const p = svgPoint(svgEl, e.clientX, e.clientY)
return [p.x, p.y]
}
svgEl.addEventListener('pointerdown', (e) => { s.handlePointer('down', ...at(e)); svgEl.setPointerCapture(e.pointerId) })
svgEl.addEventListener('pointermove', (e) => s.handlePointer('move', ...at(e)))
svgEl.addEventListener('pointerup', (e) => s.handlePointer('up', ...at(e)))
// Wheel scrolls too, but DRAGGING the body is the primary gesture — a wheel doesn't
// exist on touch and can't be expressed by a VR ray.
svgEl.addEventListener('wheel', (e) => { t.scrollBy(e.deltaY); e.preventDefault() }, { passive: false })
preview.append(
div({ style: 'display:flex;flex-direction:column;height:100%;background:#0c0e14' },
div({ style: 'flex:1;min-height:0;padding:12px' }, svgEl),
readout)
)
.preview { height: 100%; }