Viewport & minimap
Built-in controls (zoom/fit/reset/undo/redo/save/lock), toggleable minimap, live node, edge, and zoom readout.
import { useState } from 'react'
import {
XenolithGraph, XenolithControls, XenolithPanel, XenolithButton, XenolithMiniMap,
useNodes, useEdges, useViewport,
} from '@xenolithengine/graph-react'
import type { MinimapPosition } from '@xenolithengine/graph-editor'
import { DemoStage } from '../Layout.js'
import { loadDemo } from '../demo-data.js'
// Live readout driven by the reactive hooks — counts re-render on add/remove, zoom on pan/zoom.
function Stats() {
const nodes = useNodes()
const edges = useEdges()
const vp = useViewport()
return (
<XenolithPanel position="bottom-left" style={{ padding: '6px 10px', fontVariantNumeric: 'tabular-nums' }}>
<span style={{ color: 'var(--xeno-accent)' }}>{nodes.length}</span> nodes ·{' '}
<span style={{ color: 'var(--xeno-accent)' }}>{edges.length}</span> edges ·{' '}
<span style={{ color: 'var(--xeno-accent)' }}>{Math.round(vp.zoom * 100)}%</span>
</XenolithPanel>
)
}
// 3×3 grid of minimap anchors (centre toggles visibility).
const GRID: (MinimapPosition | 'center')[] = [
'top-left', 'top', 'top-right',
'left', 'center', 'right',
'bottom-left', 'bottom', 'bottom-right',
]
const ARROW: Record<string, string> = {
'top-left': '↖', top: '↑', 'top-right': '↗', left: '←', right: '→',
'bottom-left': '↙', bottom: '↓', 'bottom-right': '↘',
}
const label: React.CSSProperties = {
margin: '0 0 6px', fontSize: 11, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--xeno-muted)',
}
const cellBtn: React.CSSProperties = { width: 34, height: 30, padding: 0, display: 'grid', placeItems: 'center', fontSize: 14 }
// Everything — controls and the minimap panel — lives *inside* the editor as overlay panels. Buttons
// inherit the active theme through the editor's --xeno-* CSS vars (gold in Xen, cyan in Liquid Glass).
export function ViewportDemo() {
const [on, setOn] = useState(true)
const [pos, setPos] = useState<MinimapPosition>('bottom-right')
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={(e) => loadDemo(e)}>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left" style={{ minWidth: 150 }}>
<p style={label}>Minimap</p>
<XenolithButton active={on} onClick={() => setOn((v) => !v)} style={{ width: '100%' }}>
{on ? 'Visible' : 'Hidden'}
</XenolithButton>
<p style={{ ...label, marginTop: 14 }}>Position</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 34px)', gap: 6 }}>
{GRID.map((cell) =>
cell === 'center' ? (
<XenolithButton key="c" title="toggle" style={{ ...cellBtn, color: 'var(--xeno-muted)' }} onClick={() => setOn((v) => !v)}>⊙</XenolithButton>
) : (
<XenolithButton
key={cell as string}
active={on && pos === cell}
disabled={!on}
title={cell as string}
style={{ ...cellBtn, opacity: on ? 1 : 0.35 }}
onClick={() => setPos(cell)}
>{ARROW[cell as string]}</XenolithButton>
),
)}
</div>
</XenolithPanel>
{on && <XenolithMiniMap position={pos} />}
<Stats />
</XenolithGraph>
</DemoStage>
)
} <script setup lang="ts">
// Vue — controls, minimap, and a live readout. The hooks have to run in a child of
// <XenolithGraph> (provide/inject); that child is ViewportChrome.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { loadDemo } from '@xenolithengine/demo/scene'
import ViewportChrome from './ViewportChrome.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="loadDemo">
<ViewportChrome />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { ref } from 'vue'
import {
XenolithControls, XenolithPanel, XenolithButton, XenolithMiniMap,
useNodes, useEdges, useViewport,
} from '@xenolithengine/graph-vue'
import type { MinimapPosition } from '@xenolithengine/graph-editor'
const nodes = useNodes()
const edges = useEdges()
const vp = useViewport()
const on = ref(true)
const pos = ref<MinimapPosition>('bottom-right')
const GRID = [
'top-left', 'top', 'top-right',
'left', 'center', 'right',
'bottom-left', 'bottom', 'bottom-right',
] as const
const ARROW: Record<string, string> = {
'top-left': '↖', top: '↑', 'top-right': '↗', left: '←', right: '→',
'bottom-left': '↙', bottom: '↓', 'bottom-right': '↘',
}
function pick(cell: (typeof GRID)[number]): void {
if (cell === 'center') { on.value = !on.value; return }
pos.value = cell
}
</script>
<template>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left">
<div style="min-width:150px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Minimap</p>
<XenolithButton :active="on" style="width:100%" @click="on = !on">
{{ on ? 'Visible' : 'Hidden' }}
</XenolithButton>
<p style="margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Position</p>
<div style="display:grid;grid-template-columns:repeat(3, 34px);gap:6px;">
<template v-for="cell in GRID" :key="cell">
<XenolithButton
v-if="cell === 'center'"
style="width:34px;height:30px;padding:0;font-size:14px;color:var(--xeno-muted)"
@click="pick(cell)"
>⊙</XenolithButton>
<XenolithButton
v-else
:active="on && pos === cell"
:disabled="!on"
:style="{ width: '34px', height: '30px', padding: 0, fontSize: '14px', opacity: on ? 1 : 0.35 }"
@click="pick(cell)"
>{{ ARROW[cell] }}</XenolithButton>
</template>
</div>
</div>
</XenolithPanel>
<XenolithMiniMap v-if="on" :position="pos" />
<XenolithPanel position="bottom-left">
<span style="font-variant-numeric:tabular-nums;">
<span style="color:var(--xeno-accent)">{{ nodes.length }}</span> nodes ·
<span style="color:var(--xeno-accent)">{{ edges.length }}</span> edges ·
<span style="color:var(--xeno-accent)">{{ Math.round(vp.zoom * 100) }}%</span>
</span>
</XenolithPanel>
</template> <!-- Svelte — nodes/edges/viewport are stores. Destructure before `$`. The minimap
component shows itself while it is mounted and hides on destroy. -->
<script lang="ts">
import { xenolith, createXenolithStores, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, XenolithControls, XenolithMiniMap } from '@xenolithengine/graph-svelte/components'
import type { MinimapPosition } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
const stores = createXenolithStores()
const panelEditor = createXenolithEditorContext()
const nodes = stores.nodes
const edges = stores.edges
const viewport = stores.viewport
let on = $state(true)
let pos = $state<MinimapPosition>('bottom-right')
const GRID = [
'top-left', 'top', 'top-right',
'left', 'center', 'right',
'bottom-left', 'bottom', 'bottom-right',
] as const
const ARROW: Record<string, string> = {
'top-left': '↖', top: '↑', 'top-right': '↗', left: '←', right: '→',
'bottom-left': '↙', bottom: '↓', 'bottom-right': '↘',
}
function pick(cell: string): void {
if (cell === 'center') { on = !on; return }
pos = cell as MinimapPosition
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { panelEditor.set(e.detail); stores.editor.set(e.detail); loadDemo(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithControls position="top-right" orientation="horizontal" />
<XenolithPanel position="top-left">
<div style="min-width:150px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Minimap</p>
<XenolithButton active={on} style="width:100%" onclick={() => { on = !on }}>
{on ? 'Visible' : 'Hidden'}
</XenolithButton>
<p style="margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Position</p>
<div style="display:grid;grid-template-columns:repeat(3, 34px);gap:6px;">
{#each GRID as cell (cell)}
{#if cell === 'center'}
<XenolithButton style="width:34px;height:30px;padding:0;font-size:14px;color:var(--xeno-muted)" onclick={() => pick(cell)}>⊙</XenolithButton>
{:else}
<XenolithButton
active={on && pos === cell}
disabled={!on}
style={`width:34px;height:30px;padding:0;font-size:14px;opacity:${on ? 1 : 0.35}`}
onclick={() => pick(cell)}
>{ARROW[cell]}</XenolithButton>
{/if}
{/each}
</div>
</div>
</XenolithPanel>
{#if on}
<XenolithMiniMap position={pos} />
{/if}
<XenolithPanel position="bottom-left">
<span style="font-variant-numeric:tabular-nums;">
<span style="color:var(--xeno-accent)">{$nodes.length}</span> nodes ·
<span style="color:var(--xeno-accent)">{$edges.length}</span> edges ·
<span style="color:var(--xeno-accent)">{Math.round($viewport.zoom * 100)}%</span>
</span>
</XenolithPanel> // Solid — nodes/edges/viewport are accessors from createXenolithStores. No panel package:
// the minimap is editor.chrome, and the readout is a DOM card.
import { createSignal, For } from 'solid-js'
import { xenolith, createXenolithStores } from '@xenolithengine/graph-solid'
import type { MinimapPosition } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
const GRID = [
'top-left', 'top', 'top-right',
'left', 'center', 'right',
'bottom-left', 'bottom', 'bottom-right',
] as const
const ARROW: Record<string, string> = {
'top-left': '↖', top: '↑', 'top-right': '↗', left: '←', right: '→',
'bottom-left': '↙', bottom: '↓', 'bottom-right': '↘',
}
export function ViewportDemo() {
const stores = createXenolithStores()
const [on, setOn] = createSignal(true)
const [pos, setPos] = createSignal<MinimapPosition>('bottom-right')
function show(next: boolean): void {
setOn(next)
stores.editor()?.chrome.setMinimapVisible(next)
}
function pick(cell: string): void {
if (cell === 'center') { show(!on()); return }
const next = cell as MinimapPosition
setPos(next)
stores.editor()?.chrome.setMinimapPosition(next)
}
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
stores.setEditor(e.detail)
e.detail.chrome.setControls({ position: 'top-right', orientation: 'horizontal' })
e.detail.chrome.setMinimapVisible(true)
e.detail.chrome.setMinimapPosition('bottom-right')
loadDemo(e.detail)
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;min-width:150px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Minimap</p>
<button
type="button"
style={`width:100%;font:inherit;font-size:13px;padding:7px 12px;cursor:pointer;border-radius:8px;border:1px solid ${on() ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on() ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on() ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`}
onClick={() => show(!on())}
>{on() ? 'Visible' : 'Hidden'}</button>
<p style="margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Position</p>
<div style="display:grid;grid-template-columns:repeat(3, 34px);gap:6px;">
<For each={GRID}>{(cell) =>
cell === 'center'
? <button type="button" style="width:34px;height:30px;padding:0;font-size:14px;color:var(--xeno-muted);border-radius:8px;border:1px solid var(--xeno-border);background:var(--xeno-elevated);" onClick={() => pick(cell)}>⊙</button>
: <button
type="button"
disabled={!on()}
style={`width:34px;height:30px;padding:0;font-size:14px;border-radius:8px;cursor:${on() ? 'pointer' : 'default'};opacity:${on() ? 1 : 0.35};border:1px solid ${on() && pos() === cell ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on() && pos() === cell ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on() && pos() === cell ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`}
onClick={() => pick(cell)}
>{ARROW[cell]}</button>
}</For>
</div>
</div>
<div style="position:absolute;bottom:12px;left:12px;z-index:5;padding:6px 10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;font-variant-numeric:tabular-nums;">
<span style="color:var(--xeno-accent)">{stores.nodes().length}</span> nodes ·{' '}
<span style="color:var(--xeno-accent)">{stores.edges().length}</span> edges ·{' '}
<span style="color:var(--xeno-accent)">{Math.round(stores.viewport().zoom * 100)}%</span>
</div>
</div>
)
} // Angular — nodes$/edges$/viewport$ drive the readout. The minimap is editor.chrome
// (this adapter ships no minimap component).
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { Subscription } from 'rxjs'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { MinimapPosition } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
@Component({
selector: 'viewport-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<p class="label">Minimap</p>
<button type="button" class="btn wide" [class.on]="on()" (click)="show(!on())">
{{ on() ? 'Visible' : 'Hidden' }}
</button>
<p class="label" style="margin-top:14px;">Position</p>
<div class="grid">
@for (cell of grid; track cell) {
@if (cell === 'center') {
<button type="button" class="cell" (click)="pick(cell)">⊙</button>
} @else {
<button type="button" class="cell" [class.on]="on() && pos() === cell" [disabled]="!on()" (click)="pick(cell)">
{{ arrow[cell] }}
</button>
}
}
</div>
</div>
<div class="stats">
<span class="accent">{{ nodes() }}</span> nodes ·
<span class="accent">{{ edges() }}</span> edges ·
<span class="accent">{{ zoom() }}%</span>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; min-width:150px; padding:10px;
background:var(--xeno-panel,#1d1d1d); border:1px solid var(--xeno-border,#333);
border-radius:10px; color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif; }
.label { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:.05em;
color:var(--xeno-muted,#9a9a9a); }
.btn, .cell { font:inherit; cursor:pointer; border-radius:8px;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626);
color:var(--xeno-text,#cfcfcf); }
.btn { font-size:13px; padding:7px 12px; }
.btn.wide { width:100%; }
.btn.on, .cell.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400);
color:var(--xeno-canvas,#111); }
.btn:disabled, .cell:disabled { opacity:0.35; cursor:default; }
.grid { display:grid; grid-template-columns:repeat(3, 34px); gap:6px; }
.cell { width:34px; height:30px; padding:0; font-size:14px; }
.stats { position:absolute; bottom:12px; left:12px; z-index:5; padding:6px 10px;
background:var(--xeno-panel,#1d1d1d); border:1px solid var(--xeno-border,#333);
border-radius:10px; color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif;
font-variant-numeric:tabular-nums; }
.accent { color:var(--xeno-accent,#FCB400); }
`],
})
export class ViewportDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private sub = new Subscription()
readonly grid = [
'top-left', 'top', 'top-right',
'left', 'center', 'right',
'bottom-left', 'bottom', 'bottom-right',
] as const
readonly arrow: Record<string, string> = {
'top-left': '↖', top: '↑', 'top-right': '↗', left: '←', right: '→',
'bottom-left': '↙', bottom: '↓', 'bottom-right': '↘',
}
nodes = signal(0)
edges = signal(0)
zoom = signal(100)
on = signal(true)
pos = signal<MinimapPosition>('bottom-right')
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
editor.chrome.setControls({ position: 'top-right', orientation: 'horizontal' })
editor.chrome.setMinimapVisible(true)
editor.chrome.setMinimapPosition('bottom-right')
loadDemo(editor)
this.sub.add(this.graph.nodes$.subscribe((ns) => this.nodes.set(ns.length)))
this.sub.add(this.graph.edges$.subscribe((es) => this.edges.set(es.length)))
this.sub.add(this.graph.viewport$.subscribe((vp) => this.zoom.set(Math.round(vp.zoom * 100))))
}
show(next: boolean): void {
this.on.set(next)
this.graph.editor?.chrome.setMinimapVisible(next)
}
pick(cell: string): void {
if (cell === 'center') { this.show(!this.on()); return }
const next = cell as MinimapPosition
this.pos.set(next)
this.graph.editor?.chrome.setMinimapPosition(next)
}
ngOnDestroy(): void {
this.sub.unsubscribe()
this.graph.destroy()
}
}