wip: refactor layout and rendering path

This commit is contained in:
Wlad Meixner
2023-09-22 15:37:18 +02:00
parent d0f89bdefd
commit b4dfb5c78c
28 changed files with 1477 additions and 956 deletions
+28
View File
@@ -0,0 +1,28 @@
export type ActionClickOutsideOptions = {
onClickOutside: () => void;
whitelist?: HTMLElement[];
};
export default (node: HTMLElement, options: ActionClickOutsideOptions) => {
let whitelist = options.whitelist?.filter((el) => el !== undefined) ?? [];
let onOutsideClick = options.onClickOutside;
const detectClick = (event: MouseEvent) => {
const target = event.target as Node;
if (!node.contains(target) && !whitelist.some((el) => el.isSameNode(target))) {
onOutsideClick();
}
return;
};
const listenerOptions = { passive: true, capture: true };
document.addEventListener('click', detectClick, listenerOptions);
return {
destroy() {
document.removeEventListener('click', detectClick, listenerOptions);
},
update(params: ActionClickOutsideOptions) {
whitelist = params.whitelist?.filter((el) => el !== undefined) ?? [];
onOutsideClick = params.onClickOutside;
}
};
};
+73
View File
@@ -0,0 +1,73 @@
export const portal = (node: HTMLElement) => {
const target = document.querySelector('main');
target?.appendChild(node).focus();
return {
destroy() {
if (target?.contains(node)) {
target?.removeChild(node);
}
}
};
};
const findParentWithClass = (element: HTMLElement, className: string): HTMLElement | null => {
let el: HTMLElement | null = element;
while (el) {
if (el.classList && el.classList.contains(className)) {
return el;
}
el = el.parentElement;
}
return null;
};
export const defaultPortalRootClass = 'portal-root';
export const relativePortal = (node: HTMLElement, rootClass = defaultPortalRootClass) => {
// position node relative to its parent
const parent = node.parentElement;
if (!parent) {
return portal(node);
}
const componentBounds = node.getBoundingClientRect();
const parentBounds = parent.getBoundingClientRect();
let left = parentBounds.left;
let top = parentBounds.top + parentBounds.height + 3;
if (window.innerWidth < left + componentBounds.width) {
left = window.innerWidth - (componentBounds.width + 25);
}
if (window.innerHeight < top + componentBounds.height) {
top = window.innerHeight - (componentBounds.height + 25);
}
node.style.left = `${left}px`;
node.style.top = `${top}px`;
// TODO: Update position on resize
// Find next portal-root class along the node tree to attack
const portalRoot = findParentWithClass(node, rootClass);
let targetElement: HTMLElement | null;
if (portalRoot) {
targetElement = portalRoot;
} else {
targetElement = document.querySelector('main');
}
targetElement?.appendChild(node).focus();
return {
destroy() {
if (targetElement?.contains(node)) {
targetElement?.removeChild(node);
}
}
};
};
+19 -30
View File
@@ -18,6 +18,7 @@
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass';
import Stats from './graph/Stats.svelte';
import Graph2D from './graph/Graph2D.svelte';
export let onHover: (position: THREE.Vector2, object?: THREE.Object3D) => void = () => {};
@@ -145,30 +146,18 @@
const animate = (time: number) => {
// call all before subscribers
for (const subscriber of beforeSubscribers) {
subscriber();
subscriber(renderer, scene, camera);
}
// Update tween for all animations
TWEEN.update(time);
controls.update();
// if (mousePosition) {
// // Handle selection
// raycaster.setFromCamera(mousePosition, camera);
// // const intersections = dataRenderer.getIntersections(raycaster);
// // if (intersections.length === 0) {
// // outlinePass.selectedObjects = [];
// // onHover(mouseClientPosition, undefined);
// // } else {
// // outlinePass.selectedObjects = [intersections[0].object];
// // onHover(mouseClientPosition, intersections[0].object);
// // }
// }
composer.render();
for (const subscriber of afterSubscribers) {
subscriber();
subscriber(renderer, scene, camera);
}
requestAnimationFrame(animate);
};
@@ -256,18 +245,18 @@
}
</script>
<div class="relative w-screen h-[80vh]">
<div
bind:this={containerElement}
on:mousemove={handleHover}
on:click={handleClick}
class="w-full h-full overflow-hidden isolate"
/>
<!-- Render children only after setup complete -->
{#if isSetupComplete}
<slot />
<Stats />
{/if}
<div>
<div class="relative w-screen h-screen">
<div bind:this={containerElement} class="w-full h-full overflow-hidden isolate" />
<!-- Render children only after setup complete -->
{#if isSetupComplete}
<slot />
<Stats />
{/if}
</div>
<div class="absolute pointer-events-none bottom-0 left-2">
<Graph2D />
</div>
</div>
<style lang="scss">
+1 -1
View File
@@ -6,7 +6,7 @@
</script>
<div
class="m-1 mb-4 p-4 text-left rounded-xl bg-background-50 dark:bg-background-900 ring-1 ring-background-900/5 dark:ring-background-950/5 shadow-lg {className}"
class="m-1 mb-4 p-4 text-left rounded-xl backdrop-blur-lg bg-background-50/75 dark:bg-background-900/75 ring-1 ring-background-900/5 dark:ring-background-950/5 shadow-lg {className}"
>
{#if title !== ''}<h2 class="font-bold mb-2">{title}</h2>{/if}
<slot />
+1 -1
View File
@@ -2,7 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import type { editor } from 'monaco-editor';
import { browser } from '$app/environment';
import { settingsStore } from '$lib/store/SettingsStore';
import settingsStore from '$lib/store/SettingsStore';
let editorContainer: HTMLDivElement;
export let editor: editor.IStandaloneCodeEditor;
+4 -2
View File
@@ -2,6 +2,7 @@
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import { fade, fly } from 'svelte/transition';
import { defaultPortalRootClass, portal } from '$lib/actions/portal';
type DialogSize = 'small' | 'medium' | 'large';
@@ -43,13 +44,14 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
role="dialog"
use:portal
transition:fade={{ duration: 100 }}
class="modal-overlay fixed top-0 bottom-0 left-0 right-0 w-full h-full bg-opacity-80 dark:bg-opacity-60 bg-slate-200 dark:bg-background-950"
class="modal-overlay {defaultPortalRootClass} fixed top-0 bottom-0 left-0 right-0 w-full h-full bg-opacity-80 dark:bg-opacity-60 bg-slate-200 dark:bg-background-950"
on:mousedown|self={toggleDialog}
>
<div
transition:fly={{ y: -120, delay: 25, duration: 150 }}
class="modal rounded-3xl shadow-xl bg-background-50 dark:bg-background-800 {size}"
class="modal rounded-3xl shadow-xl backdrop-blur-lg bg-background-50/75 dark:bg-background-900/75 {size}"
>
{#if $$slots.title}<div class="pb-4 mb-2 border-b">
<h2 class="font-bold text-xl"><slot name="title" /></h2>
+1 -1
View File
@@ -33,7 +33,7 @@
</script>
<div
class="border-4 rounded-xl p-6 border-background-300 dark:border-background-700 text-center border-dotted"
class="border-4 rounded-xl p-6 border-background-300/70 dark:border-background-700/60 text-center border-dotted"
class:dragging={isDragging}
on:dragenter={handleDragEnter}
on:dragover={handleDragOver}
+15 -22
View File
@@ -4,6 +4,8 @@
import Button from './button/Button.svelte';
import { ChevronDownIcon, ChevronUpIcon } from 'svelte-feather-icons';
import { ButtonSize } from './button/type';
import { relativePortal } from '$lib/actions/portal';
import clickOutside, { type ActionClickOutsideOptions } from '$lib/actions/clickOutside';
export let isOpen: boolean = false;
export let disabled: boolean = false;
@@ -12,11 +14,15 @@
let className: string | undefined = undefined;
export { className as class };
let popoverElement: HTMLDivElement;
const outsideActionParams: ActionClickOutsideOptions = {
onClickOutside
};
function toggleDropdown() {
const toggleDropdown = () => {
console.log('hello there before', isOpen);
isOpen = !isOpen;
}
console.log('hello there after', isOpen);
};
interface $$Slots {
button: {};
@@ -32,30 +38,15 @@
`
};
}
function handleClickOutside(event: MouseEvent) {
if (popoverElement && !popoverElement.contains(event.target as Node)) {
isOpen = false;
}
function onClickOutside() {
isOpen = false;
}
// Close the dropdown when clicking outside
onMount(() => {
if (browser) {
window.addEventListener('click', handleClickOutside);
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('click', handleClickOutside);
}
});
</script>
<div
class="relative mb-2 ring-offset-2 rounded-md ring-offset-background-50 dark:ring-offset-background-800 {className}"
class:ring-4={isOpen}
bind:this={popoverElement}
>
<Button
size={ButtonSize.MD}
@@ -74,8 +65,10 @@
{#if isOpen}
<div
use:clickOutside={outsideActionParams}
use:relativePortal
transition:fadeSlide={{ duration: 100 }}
class="z-10 origin-top-left absolute left-0 mt-2 w-64 overflow-hidden rounded-xl shadow-2xl shadow-background-700 dark:shadow-background-950 bg-background-50 dark:bg-background-800 ring-background-200/5 dark:ring-background-950/5 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
class="z-1000 origin-top-left absolute left-0 mt-2 w-64 overflow-hidden rounded-xl shadow-2xl shadow-background-700 dark:shadow-background-950 bg-background-50/80 dark:bg-background-800/95 backdrop-blur-sm ring-background-200/5 dark:ring-background-950/5 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<div class="dropdown-content w-full h-full overflow-y-auto max-h-[350px]">
<div role="none">
+7 -5
View File
@@ -28,8 +28,6 @@
export let values: R[] | undefined = undefined;
export let optionConstructor: OptionConstructor | undefined = undefined;
let dummy = 0;
$: {
selectionLabel = labelForSelection(options.filter((o) => selection.has(o.value)));
}
@@ -158,9 +156,13 @@
{/each}
</ul>
{#if !singular}
<div class="border-t p-4 flex justify-end gap-2">
<Button size="sm" color="primary" on:click={selectAll}>Select all</Button>
<Button size="sm" on:click={clearAll}>Clear</Button>
<div
class="sticky bottom-0 left-0 right-0 border-t dark:border-t-background-700 bg-background-50 dark:bg-background-800 backdrop-blur-sm"
>
<div class="p-2 flex justify-end gap-2">
<Button size="sm" color="primary" on:click={selectAll}>Select all</Button>
<Button size="sm" on:click={clearAll}>Clear</Button>
</div>
</div>
{/if}
</div>
+32 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { dataStore } from '$lib/store/dataStore/DataStore';
import filterStore, { type IFilterStoreGraphOptions } from '$lib/store/filterStore/FilterStore';
import filterStore from '$lib/store/filterStore/FilterStore';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
import { onMount } from 'svelte';
import Button from './button/Button.svelte';
import Card from './Card.svelte';
@@ -9,17 +10,19 @@
import OptionRenderer from './OptionRenderer.svelte';
import Divider from './base/Divider.svelte';
import {
InfoIcon,
LayersIcon,
MoonIcon,
PlusIcon,
RefreshCcwIcon,
SettingsIcon,
Trash2Icon,
SunIcon,
XIcon
} from 'svelte-feather-icons';
import { ButtonColor, ButtonSize, ButtonVariant } from './button/type';
import Dialog from './Dialog.svelte';
import DropZone from './DropZone.svelte';
import TableSelection from './TableSelection.svelte';
import QueryEditor from './QueryEditor.svelte';
let optionsStore: GraphOptions['optionsStore'] | undefined;
let isFilterBarOpen: boolean = true;
@@ -64,15 +67,36 @@
}
</script>
<div class="absolute right-4 pt-4 t-0 bottom-0 w-96 min-h-full overflow-y-auto">
<div class="mb-4 flex justify-end mr-1">
<div class="absolute right-4 pt-4 t-0 top-0 w-96 max-h-full overflow-y-auto">
<div class="mb-4 gap-3 flex justify-end mr-1">
<Button
size={ButtonSize.SM}
size={ButtonSize.LG}
color={ButtonColor.SECONDARY}
on:click={settingsStore.toggleThemeMode}
>
<div class="py">
{#if $settingsStore.theme === Theme.Dark}
<MoonIcon size="20" />
{:else}
<SunIcon size="20" />
{/if}
</div>
</Button>
<Dialog size={'large'}>
<Button slot="trigger" color={ButtonColor.SECONDARY} size={ButtonSize.LG}>
<InfoIcon slot="leading" size="20" />
SQL Editor
</Button>
<svelte:fragment slot="title">SQL Query Editor</svelte:fragment>
<QueryEditor />
</Dialog>
<Button
size={ButtonSize.LG}
color={isFilterBarOpen ? ButtonColor.PRIMARY : ButtonColor.SECONDARY}
on:click={_toggleFilterBar}
>
<div class="py-1">
<SettingsIcon />
<div class="py">
<SettingsIcon size="20" />
</div>
</Button>
</div>
+3 -2
View File
@@ -4,6 +4,7 @@
import type { FilterEntry } from '../../routes/graph/proxy+page.server';
import DropZone from './DropZone.svelte';
import DropdownSelect from './DropdownSelect.svelte';
import Divider from './base/Divider.svelte';
function onSelectTable(selectionOptions: { label: string; value: FilterEntry }[]) {
const selectedTables = $filterStore.preloadedTables.filter(
@@ -21,9 +22,9 @@
<p class="mb-2">from filter data provided by us</p>
<DropdownSelect onSelect={onSelectTable} options={$filterStore.preloadedTables} />
<div class="flex mt-5 mb-5 items-center justify-center">
<div class="border-t dark:border-background-700 w-full" />
<Divider />
<div class="mx-4 opacity-50">OR</div>
<div class="border-t w-full dark:border-background-700" />
<Divider />
</div>
<p class="mb-2">your own dataset in CSV format</p>
<DropZone onFileDropped={filesDropped} />
+1 -1
View File
@@ -1 +1 @@
<hr class="mt-4 mb-2 dark:border-background-800" />
<hr class="mt-4 mb-2 w-full dark:border-background-950" />
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import * as d3 from 'd3';
import { onMount } from 'svelte';
import Card from '../Card.svelte';
let graphElement: HTMLDivElement;
// set the dimensions and margins of the graph
var margin = { top: 20, right: 30, bottom: 30, left: 50 },
width = 260 - margin.left - margin.right,
height = 300 - margin.top - margin.bottom;
onMount(async () => {
return;
// append the svg object to the body of the page
var svg = d3
.select(graphElement)
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// get the data
console.log('Hello', data);
// List of groups (here I have one group per column)
var allGroup = d3
.map(data, function (d) {
return d.Species;
})
.keys();
// add the options to the button
// d3.select('#selectButton')
// .selectAll('myOptions')
// .data(allGroup)
// .enter()
// .append('option')
// .text(function (d) {
// return d;
// }) // text showed in the menu
// .attr('value', function (d) {
// return d;
// }); // corresponding value returned by the button
// add the x Axis
var x = d3.scaleLinear().domain([0, 12]).range([0, width]);
svg
.append('g')
.attr('transform', 'translate(0,' + height + ')')
.call(d3.axisBottom(x));
// add the y Axis
var y = d3.scaleLinear().range([height, 0]).domain([0, 0.4]);
svg.append('g').call(d3.axisLeft(y));
// Compute kernel density estimation for the first group called Setosa
var kde = kernelDensityEstimator(kernelEpanechnikov(3), x.ticks(140));
var density = kde(
data
.filter(function (d) {
return d.Species == 'setosa';
})
.map(function (d) {
return +d.Sepal_Length;
})
);
// Plot the area
var curve = svg
.append('g')
.append('path')
.attr('class', 'mypath')
.datum(density)
.attr('fill', '#69b3a2')
.attr('opacity', '.8')
.attr('stroke', '#000')
.attr('stroke-width', 1)
.attr('stroke-linejoin', 'round')
.attr(
'd',
d3
.line()
.curve(d3.curveBasis)
.x(function (d) {
return x(d[0]);
})
.y(function (d) {
return y(d[1]);
})
);
// A function that update the chart when slider is moved?
function updateChart(selectedGroup) {
// recompute density estimation
kde = kernelDensityEstimator(kernelEpanechnikov(3), x.ticks(40));
var density = kde(
data
.filter(function (d) {
return d.Species == selectedGroup;
})
.map(function (d) {
return +d.Sepal_Length;
})
);
// update the chart
curve
.datum(density)
.transition()
.duration(1000)
.attr(
'd',
d3
.line()
.curve(d3.curveBasis)
.x(function (d) {
return x(d[0]);
})
.y(function (d) {
return y(d[1]);
})
);
}
// // Listen to the slider?
// d3.select('#selectButton').on('change', function (d) {
// selectedGroup = this.value;
// updateChart(selectedGroup);
// });
});
// Function to compute density
function kernelDensityEstimator(kernel, X) {
return function (V) {
return X.map(function (x) {
return [
x,
d3.mean(V, function (v) {
return kernel(x - v);
})
];
});
};
}
function kernelEpanechnikov(k) {
return function (v) {
return Math.abs((v /= k)) <= 1 ? (0.75 * (1 - v * v)) / k : 0;
};
}
</script>
<!-- Create a div where the graph will take place -->
<Card class="p-0">
<!-- Initialize a select button -->
<div bind:this={graphElement} />
</Card>
+1 -1
View File
@@ -31,6 +31,6 @@
</script>
<div
class="minimap absolute isolate left-0 bottom-20 w-[200px] h-[200px]"
class="minimap absolute isolate z-10 right-0 bottom-0 w-[190px] h-[190px]"
bind:this={renderTargetEl}
/>
+44 -8
View File
@@ -8,6 +8,8 @@
import type { Unsubscriber } from 'svelte/store';
import Button from '../button/Button.svelte';
import type { Axis } from '$lib/rendering/AxisRenderer';
import { ButtonColor, ButtonSize } from '../button/type';
import { Vector3 } from 'three';
export let options: PlaneGraphOptions;
@@ -20,12 +22,16 @@
const bootstrap = () => {
const { camera: graphCamera, scene, domElement } = graphService.getValues();
dataRenderer?.setup(domElement, scene, graphCamera);
scene.add(dataRenderer);
// call render event of graph before scene render is done
graphService.registerOnBeforeRender(dataRenderer.onBeforeRender.bind(dataRenderer));
};
const updateWithData = (data?: IPlaneRendererData) => {
if (!data || !dataRenderer) return;
const { camera: graphCamera, scene, domElement } = graphService.getValues();
dataRenderer.setAxisLabelRenderer(labelForAxis);
dataRenderer.updateWithData(data);
layerVisibility = dataRenderer.getLayerVisibility();
@@ -42,9 +48,39 @@
unsubscriber?.();
});
function formatPowerOfTen(num: number) {
if (num === 0) return '0';
let exponent = Math.floor(Math.log10(Math.abs(num)));
return `10^${exponent}`;
}
const labelForAxis = (axis: Axis, segment: number) => {
$dataStore?.layers
return segment.toFixed(2) *;
const store = dataStore;
if (!store) {
return;
}
const range = $dataStore!.ranges[axis];
const tileRange = $dataStore!.tileRange[axis as keyof IPlaneRendererData['tileRange']];
if (!range || !tileRange) {
return segment.toFixed(2);
}
const [min, max] = range;
// Skip every second value
if (segment % 2 === 0) {
return null;
}
const value = (segment / tileRange) * max;
if (Math.abs(value) < 0.01 || Math.abs(value) > 1000) {
return formatPowerOfTen(value);
// const exponent = Math.floor(Math.log10(Math.abs(value)));
// const coefficient = value / Math.pow(10, exponent);
// return `${coefficient.toFixed(2)}e${exponent >= 0 ? '+' : ''}${exponent}`;
}
return value.toFixed(2).toString();
};
const toggleLayerVisibility = (index: number) => {
@@ -63,7 +99,7 @@
};
</script>
<div class="plane-graph-ui legend absolute isolate left-4 bottom-80 w-[250px]">
<div class="plane-graph-ui legend absolute isolate left-2 top-16 w-[250px]">
<Card title="Layers">
{#if $dataStore}
{#each layerVisibility as visible, index}
@@ -83,15 +119,15 @@
{/each}
{/if}
<Button
size="sm"
color="secondary"
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every((l) => l === true)}
on:click={showAllLayers}>Show all</Button
>
<Button
size="sm"
color="secondary"
size={ButtonSize.SM}
color={ButtonColor.SECONDARY}
class="mt-2"
disabled={layerVisibility.every((l) => l === false)}
on:click={hideAllLayers}>Hide all</Button
+5 -1
View File
@@ -2,7 +2,11 @@ import * as THREE from 'three';
export type GraphUnsubscribe = () => void;
export type GraphRenderLoopCallback = () => void;
export type GraphRenderLoopCallback = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera
) => void;
// let displatFilter: ;
export type GraphService = {
+247 -210
View File
@@ -1,28 +1,36 @@
import type { DeepPartial } from '$lib/store/filterStore/types';
import * as THREE from 'three';
import { MeshLine, MeshLineMaterial } from 'three.meshline';
import { TextTexture, type TextTextureOptions } from './textures/TextTexture';
export interface AxisLabelOptions {
color: THREE.ColorRepresentation;
font: string;
fontSize: number;
fontLineHeight: number;
text: string;
}
// custom label renderer
// the following behavouir is expected:
// - Returns string -> string will be rendered
// - Returns null -> Nothing will be rendered
// - Returns undefined -> default renderer used
export type AxisLabelRenderer = (
axis: Axis,
segment: number,
totalSegments: number
) => string | null | undefined;
export interface AxisOptions {
lineWidth: number;
lineColor: THREE.ColorRepresentation;
label: AxisLabelOptions;
textOptions: TextTextureOptions;
labelText: string;
segments?: number;
labelForSegment?: (segment: number) => string;
labelScale?: number;
labelForSegment?: AxisLabelRenderer;
segmentSize?: number;
}
export interface AxisRendererOptions {
size: THREE.Vector3;
labelScale: number;
origin: THREE.Vector3;
labelForSegment?: AxisLabelRenderer;
segments?: number;
x: AxisOptions;
y: AxisOptions;
z: AxisOptions;
@@ -55,125 +63,265 @@ const defaultAxisRendererOptions: AxisRendererOptions = {
x: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'x'
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X',
segments: 10
},
y: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'y'
}
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'X'
},
z: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'z'
}
textOptions: {
...defaultAxisLabelOptions
},
labelText: 'Z'
}
};
class TextTexture extends THREE.CanvasTexture {
private canvas?: HTMLCanvasElement;
private context?: CanvasRenderingContext2D;
export class SingleAxis extends THREE.Group {
private static defaultSegmentSize = 0.0005;
private static defaultLabelSize = 0.001;
private static fontAspectRation = 3 / 4;
constructor(text: string, options: AxisLabelOptions) {
// Create a canvas element
const canvas = document.createElement('canvas');
canvas.width = text.length * options.fontSize;
canvas.height = options.fontSize * options.fontLineHeight;
private options: AxisOptions;
private direction: THREE.Vector3;
private axis: Axis;
// Get the 2D rendering context of the canvas
const context = canvas.getContext('2d');
axisMesh?: THREE.Mesh;
label?: THREE.Sprite;
segmentLines: THREE.Group = new THREE.Group();
segmentLabels: THREE.Group = new THREE.Group();
if (!context) {
throw new Error('Failed to create canvas context');
constructor(axis: Axis, options: AxisOptions) {
super();
this.axis = axis;
this.options = options;
switch (axis) {
case Axis.X:
this.direction = new THREE.Vector3(1, 0, 0);
break;
case Axis.Y:
this.direction = new THREE.Vector3(0, 1, 0);
break;
case Axis.Z:
this.direction = new THREE.Vector3(0, 0, 1);
break;
}
// Set the font properties
context.font = `${options.fontSize}px ${options.font}`;
// Set the text color
context.fillStyle = new THREE.Color(options.color).getStyle();
// Set the text alignment and baseline
context.textAlign = 'center';
context.textBaseline = 'middle';
// Calculate the text position in the center of the canvas
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const textX = canvasWidth / 2;
const textY = canvasHeight / 2;
// Render the text on the canvas
context.fillText(text, textX, textY);
// Create a texture from the canvas
super(canvas);
this.canvas = canvas;
this.context = context;
// TODO: maybe reuse canvas if we update the labels frequently
// Remove the canvas from the DOM
// document.removeChild(textCanvas);
this.createAxis();
this.renderAxisSegments();
}
dispose(): void {
this.canvas?.remove();
super.dispose();
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
const defaultNormal = this.direction;
// make labels transparent when angle is too sharp
const gridNormal = defaultNormal.clone().transformDirection(this.matrixWorld);
const dot = cameraDirection.dot(gridNormal);
const opacity = Math.max(1 - Math.pow(Math.abs(dot), 2), 0);
// TODO: if the leads to performance issues use other method
this.segmentLabels.children.forEach(
(child) => ((child as THREE.Sprite).material.opacity = opacity)
);
};
get fontAspectRation() {
return SingleAxis.fontAspectRation;
}
private createAxis() {
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
this.direction
]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const material = new MeshLineMaterial({
color: this.options.lineColor,
lineWidth: this.options.lineWidth
});
const line = new THREE.Mesh(meshLine.geometry, material);
// Scale the line along the direction vector to the desired length
const scaleFactor = this.direction.clone().multiplyScalar(0.5);
// Compute width scale depending on text length
line.scale.set(scaleFactor.x, scaleFactor.y, scaleFactor.z);
this.axisMesh = line;
this.add(line);
const spriteMaterial = new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(this.options.labelText, this.options.textOptions)
});
const label = new THREE.Sprite(spriteMaterial);
const labelScale = this.options.labelScale ?? SingleAxis.defaultLabelSize;
const labelOffset = this.direction.clone().multiplyScalar(0.5);
label.position.set(
labelOffset.x === 0 ? -labelScale : labelOffset.x,
labelOffset.y === 0 ? -labelScale : labelOffset.y,
labelOffset.z === 0 ? -labelScale : labelOffset.z
);
const textWidth = this.options.labelText.length * this.fontAspectRation;
if (this.axis === Axis.Y) {
// FIXME: use rotation instead of magic constant
label.position.x = -0.1 - textWidth * 0.05;
}
label.scale.set(labelScale * textWidth, labelScale, labelScale);
this.label = label;
this.add(label);
}
renderSegmentLabel(text: string, segmentIndex: number): THREE.Sprite {
const textWidth = text.length * this.fontAspectRation;
const numSegments = this.options.segments!;
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(text, {
...this.options.textOptions,
fontSize: this.options.textOptions.fontSize * 0.5
})
})
);
const labelOffset = this.direction.clone().multiplyScalar(segmentIndex / numSegments);
const labelScale = this.options.labelScale ?? SingleAxis.defaultSegmentSize;
const sizeScale = 4 / numSegments;
const nonMainAxisOffset = (this.axis === Axis.Y ? 0.7 : 0.2) + 0.1 * sizeScale;
label.position.set(
labelOffset.x === 0 ? -nonMainAxisOffset * labelScale : labelOffset.x,
labelOffset.y === 0 ? -nonMainAxisOffset * labelScale : labelOffset.y,
labelOffset.z === 0 ? -nonMainAxisOffset * labelScale : labelOffset.z
);
label.scale.set(labelScale * textWidth, labelScale, labelScale).multiplyScalar(sizeScale);
return label;
}
private labelFormatter(segmentIndex: number) {
if (!this.options.segments) {
return '';
}
if (this.options.labelForSegment) {
const label = this.options.labelForSegment(this.axis, segmentIndex, this.options.segments);
if (label !== undefined) {
return label;
}
}
return (segmentIndex / this.options.segments).toPrecision(2).toString();
}
renderAxisSegments() {
if (!this.options.segments) {
return;
}
// remove old segments
this.segmentLabels.clear();
this.segmentLines.clear();
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
// Get 90 deg angle to direction vector
new THREE.Vector3(1, 0, 0)
]);
for (let i = 0; i <= this.options.segments; i++) {
// Render segments
const material = new MeshLineMaterial({
color: this.options.lineColor,
lineWidth: this.options.lineWidth
});
const segmentLine = new THREE.Mesh(geometry, material);
segmentLine.position.set(0, 0, 0);
// segmentLine.scale.set(scale.x, scale.y, scale.z);
this.segmentLines.add(segmentLine);
// Render segment
const segmentLabelText = this.labelFormatter(i);
// if label renderer returns undefined
if (segmentLabelText === null) {
continue;
}
const segmentLabel = this.renderSegmentLabel(segmentLabelText, i);
this.segmentLabels.add(segmentLabel);
}
this.add(this.segmentLabels);
this.add(this.segmentLines);
}
}
export class AxisRenderer extends THREE.Object3D {
private options: AxisRendererOptions;
private mapAxis = new Map<Axis, SingleAxis>();
constructor(options: DeepPartial<AxisRendererOptions> = {}) {
constructor(options: Partial<AxisRendererOptions> = {}) {
super();
const optionsWithDefaults: AxisRendererOptions = {
...defaultAxisRendererOptions,
...options,
x: {
...defaultAxisRendererOptions.x,
...(options.x ?? {}),
label: {
...defaultAxisRendererOptions.x.label,
...(options.x?.label ?? {})
}
},
y: {
...defaultAxisRendererOptions.y,
...(options.y ?? {}),
label: {
...defaultAxisRendererOptions.y.label,
...(options.y?.label ?? {})
}
},
z: {
...defaultAxisRendererOptions.z,
...(options.z ?? {}),
label: {
...defaultAxisRendererOptions.z.label,
...(options.z?.label ?? {})
}
}
const initialOptions: AxisRendererOptions = {
...defaultAxisRendererOptions
};
this.options = optionsWithDefaults;
this.render();
for (const axis of [Axis.X, Axis.Y, Axis.Z]) {
const axisOptions: AxisOptions = {
...defaultAxisRendererOptions[axis],
labelForSegment: options.labelForSegment ?? defaultAxisRendererOptions.labelForSegment,
labelScale: options.labelScale ?? defaultAxisRendererOptions.labelScale,
...(options[axis] ?? {})
};
initialOptions[axis] = axisOptions;
const singleAxis = new SingleAxis(axis, axisOptions);
this.mapAxis.set(axis, singleAxis);
this.add(singleAxis);
}
this.options = initialOptions;
}
render(): void {
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
this.mapAxis.forEach((axisObj) =>
axisObj.onBeforeRender(renderer, scene, camera, geometry, material, group)
);
};
setup(): void {
this.clear();
this.add(this.createAxis(this.options.x, new THREE.Vector3(1, 0, 0)));
this.add(this.createAxis(this.options.y, new THREE.Vector3(0, 1, 0)));
this.add(this.createAxis(this.options.z, new THREE.Vector3(0, 0, 1)));
}
destroy(): void {
@@ -181,115 +329,4 @@ export class AxisRenderer extends THREE.Object3D {
this.remove();
this.clear();
}
private createAxis = (options: AxisOptions, direction: THREE.Vector3): THREE.Object3D => {
const axis = new THREE.Group();
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
direction
]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const material = new MeshLineMaterial({
color: options.lineColor,
lineWidth: options.lineWidth
});
const line = new THREE.Mesh(meshLine.geometry, material);
// Scale the line along the direction vector to the desired length
const scaleFactor = direction.clone().multiply(this.options.size.clone());
// Compute width scale depending on text length
line.scale.set(scaleFactor.x, scaleFactor.y, scaleFactor.z);
axis.add(line);
// Draw line segments
console.log('!!!Drawing segments', options.segments, scaleFactor);
if (options.segments) {
const segmentDirection = direction
.clone()
.applyAxisAngle(new THREE.Vector3(1, 0, 1), Math.PI / 2);
console.log('Segment direction');
const segmentGap = 1 / (options.segments ?? 1);
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
// Get 90 deg angle to direction vector
new THREE.Vector3(100, 0, 0)
]);
for (let i = 0; i <= options.segments; i++) {
const material = new MeshLineMaterial({
color: options.lineColor,
lineWidth: options.lineWidth
});
const segmentLine = new THREE.Mesh(geometry, material);
segmentLine.position.set(0, 0, 0);
segmentLine.scale.set(scaleFactor.x, scaleFactor.y, scaleFactor.z);
axis.add(segmentLine);
const labelText =
options.labelForSegment?.(i) ?? (i / options.segments).toPrecision(2).toString();
const textWidth = labelText.length * 0.75;
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(labelText, {
...options.label,
fontSize: options.label.fontSize * 0.5
})
})
);
const labelOffset = direction
.clone()
.multiply(this.options.size.clone().multiplyScalar(i / options.segments));
const sizeScale = 4 / options.segments;
const nonMainAxisOffset = 0.2 + 0.1 * sizeScale;
label.position.set(
labelOffset.x === 0 ? -nonMainAxisOffset * this.options.labelScale : labelOffset.x,
labelOffset.y === 0 ? -nonMainAxisOffset * this.options.labelScale : labelOffset.y,
labelOffset.z === 0 ? -nonMainAxisOffset * this.options.labelScale : labelOffset.z
);
label.scale
.set(
this.options.labelScale * textWidth,
this.options.labelScale,
this.options.labelScale
)
.multiplyScalar(sizeScale);
axis.add(label);
}
}
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
depthWrite: false,
map: new TextTexture(options.label.text, options.label)
})
);
const labelOffset = direction.clone().multiply(this.options.size.clone().multiplyScalar(0.5));
label.position.set(
labelOffset.x === 0 ? -1 * this.options.labelScale : labelOffset.x,
labelOffset.y === 0 ? -1 * this.options.labelScale : labelOffset.y,
labelOffset.z === 0 ? -1 * this.options.labelScale : labelOffset.z
);
const textWidth = options.label.text.length * 0.75;
label.scale.set(
this.options.labelScale * textWidth,
this.options.labelScale,
this.options.labelScale
);
axis.add(label);
return axis;
};
}
+9 -6
View File
@@ -1,9 +1,9 @@
import { Vector3 } from 'three';
import { Object3D, Vector3 } from 'three';
export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> {
export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> extends Object3D {
public scene: THREE.Scene | undefined = undefined;
public camera: THREE.Camera | undefined = undefined;
public size: THREE.Vector3 = new Vector3(1, 1, 1);
// public size: THREE.Vector3 = new Vector3(1, 1, 1);
public renderContainer: HTMLElement | undefined = undefined;
public onDataPointSelected:
@@ -19,7 +19,12 @@ export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> {
// Set initial size
const bounds = renderContainer.getBoundingClientRect();
this.size = new Vector3(bounds.width, bounds.width, bounds.width);
// NOTE: apply reasonable scaling for graph may differ per graph implementation
const size = Math.min(bounds.width, bounds.height) * 0.6;
this.scale.set(size, size, size);
// Center in screen
this.position.y = -0.5 * size;
// this.position.x = -0.5 * size;
}
/**
@@ -29,6 +34,4 @@ export abstract class GraphRenderer<T = unknown, InstanceMetaInfo = any> {
abstract updateWithData(data: T, colorPalette?: THREE.ColorRepresentation[]): void;
abstract getIntersections(raycaster: THREE.Raycaster): THREE.Intersection[];
abstract setScale(scale: THREE.Vector3): void;
}
+298 -281
View File
@@ -1,28 +1,15 @@
import {
BoxGeometry,
BufferGeometry,
Mesh,
MeshBasicMaterial,
Object3D,
OrthographicCamera,
Raycaster,
Scene,
Vector2,
WebGLRenderer,
type Face,
Color,
Vector3,
TubeGeometry,
CurvePath,
Curve,
CylinderGeometry,
Euler,
DirectionalLight,
TetrahedronGeometry,
SphereGeometry,
EdgesGeometry,
LineSegments,
LineBasicMaterial
DirectionalLight
} from 'three';
import { AxisRenderer } from './AxisRenderer';
import * as THREE from 'three';
@@ -43,26 +30,75 @@ const grayColorList = [
'#D6D6D6' // Very Light Gray
];
enum SelectionType {
side,
edge,
corner
}
export class Minimap {
private scene: Scene;
private scene!: Scene;
private orientationCube?: Mesh<BoxGeometry, MeshBasicMaterial[]>;
private orientationEdges?: THREE.Group;
private renderer: THREE.WebGLRenderer;
private camera: THREE.Camera;
private trackedCamera: THREE.Camera | undefined = undefined;
private raycaster = new Raycaster();
private stopped = false;
private cubeSize = 20;
private bevelSize = 0.1;
public selectionColor = new Color(0xffff00);
public color = new Color(grayColorList[9]);
public borderColor = new Color(grayColorList[3]);
private mousePosition: THREE.Vector2 = new Vector2(0, 0);
private mouseClientPosition: THREE.Vector2 = new Vector2(0, 0);
private mouseInside = false;
private stopped = false;
private cubeSize = 20;
private bevelSize = 0.075;
private mouseDownPos = { x: 0, y: 0 };
private mouseUpPos = { x: 0, y: 0 };
private controls!: OrbitControls;
private controls: OrbitControls;
private selection?: {
type: SelectionType;
index: number;
};
private previousFaceIndex: number | null = null;
constructor(element: HTMLElement) {
const bounds = element.getBoundingClientRect();
this.camera = new OrthographicCamera(
bounds.width / -8,
bounds.width / 8,
bounds.height / 8,
bounds.height / -8,
-bounds.width * 4,
bounds.width * 4
);
this.camera.position.z = Math.min(bounds.width, bounds.height);
onCanvasHover(event: MouseEvent) {
this.setupEvents(element);
this.setupScene();
this.renderCube();
this.renderCubeEdges();
// Setup renderer
this.renderer = new WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0x000000, 0);
this.renderer.setSize(bounds.width, bounds.height);
element.appendChild(this.renderer.domElement);
this.setupControls();
this.startAnimationLoop();
}
destroy() {
this.stopped = true;
}
private onCanvasHover(event: MouseEvent) {
// Normalize mouse position
const bounds = this.renderer.domElement.getBoundingClientRect();
this.mouseClientPosition.x = event.clientX - bounds.left;
@@ -71,40 +107,9 @@ export class Minimap {
this.mousePosition.x = (this.mouseClientPosition.x / bounds.width) * 2 - 1;
this.mousePosition.y = -(this.mouseClientPosition.y / bounds.height) * 2 + 1.0;
}
onCanvasClick(event: MouseEvent) {
if (this.previousFaceIndex !== null) {
this.lookAtFace(this.previousFaceIndex);
}
}
// round-edged box
createBoxWithRoundedEdges(
width: number,
height: number,
depth: number,
r: number,
smoothness: number
) {
const shape = new THREE.Shape();
const eps = 0.00001;
const radius = r - eps;
shape.absarc(eps, eps, eps, -Math.PI / 2, -Math.PI, true);
shape.absarc(eps, height - radius * 2, eps, Math.PI, Math.PI / 2, true);
shape.absarc(width - radius * 2, height - radius * 2, eps, Math.PI / 2, 0, true);
shape.absarc(width - radius * 2, eps, eps, 0, -Math.PI / 2, true);
const geometry = new THREE.ExtrudeGeometry(shape, {
// amount: depth - radius0 * 2,
bevelEnabled: true,
bevelSegments: 1,
steps: 1,
bevelSize: radius,
bevelThickness: r,
curveSegments: smoothness
});
geometry.center();
return geometry;
private onCanvasClick(event: MouseEvent) {
this.lookAtSelection();
}
setupControls() {
@@ -118,22 +123,7 @@ export class Minimap {
this.controls.dampingFactor = 0.1;
}
private mouseDownPos = { x: 0, y: 0 };
private mouseUpPos = { x: 0, y: 0 };
constructor(element: HTMLElement) {
const bounds = element.getBoundingClientRect();
this.camera = new OrthographicCamera(
bounds.width / -8,
bounds.width / 8,
bounds.height / 8,
bounds.height / -8,
-bounds.width * 4,
bounds.width * 4
);
console.log('bounds', bounds);
this.camera.position.z = Math.min(bounds.width, bounds.height);
private setupEvents(element: HTMLElement) {
// Setup event listeners
element.addEventListener('mousemove', this.onCanvasHover.bind(this));
@@ -159,28 +149,41 @@ export class Minimap {
element.addEventListener('mouseleave', () => {
this.mouseInside = false;
});
}
private setupScene() {
this.scene = new Scene();
// side lines
// TODO: fixme render lines on top of cube
// Add light to scene
const light = new DirectionalLight(0xffffff, 1);
light.position.set(0, 0, 1);
}
private renderCube() {
const cubeGeometry = new BoxGeometry(1, 1, 1);
const materials = [
new MeshBasicMaterial({ color: grayColorList[8] }),
new MeshBasicMaterial({ color: grayColorList[8] }),
new MeshBasicMaterial({ color: grayColorList[8] }),
new MeshBasicMaterial({ color: grayColorList[8] }),
new MeshBasicMaterial({ color: grayColorList[8] }),
new MeshBasicMaterial({ color: grayColorList[8] })
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color }),
new MeshBasicMaterial({ color: this.color })
];
this.orientationCube = new Mesh(cubeGeometry, materials);
this.orientationCube.scale.set(this.cubeSize, this.cubeSize, this.cubeSize);
this.scene.add(this.orientationCube);
const bevelSize = 0.075;
}
private renderCubeEdges() {
// Create beveled edge for one edge (as an example)
const edgeShape = new THREE.Shape();
edgeShape.moveTo(0, 0);
edgeShape.lineTo(bevelSize, 0);
edgeShape.lineTo(0.0, bevelSize);
edgeShape.lineTo(this.bevelSize, 0);
edgeShape.lineTo(0.0, this.bevelSize);
edgeShape.lineTo(0, 0);
const extrudeSettings = {
@@ -189,23 +192,6 @@ export class Minimap {
bevelEnabled: false
};
const edgeMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const colors = [
0xff0000, // front top edge
0x00ff00, // back top edge
0x0000ff, // left top edge
0xffff00, // right top edge
0xff00ff, // front bottom edge
0x00ffff, // back bottom edge
0xffccff, // left bottom edge
0x000000, // right bottom edge
0xff0000, // X/Y Edge (right,front)
0x00ff00, // X/Y Edge (right,back)
0x0000ff, // X/Y Edge (left,back)
0xccefb0 // X/Y Edge (left,front)
];
// Position and add all 12 beveled edges
const edgesPositionsRotations = [
// Top horizontal edges
@@ -226,152 +212,64 @@ export class Minimap {
{ pos: [-0.5, -0.5, 0.5], rot: [-Math.PI / 2, 0, Math.PI] } // Front top edge
];
this.orientationEdges = new THREE.Group();
const orientationEdges = new THREE.Group();
edgesPositionsRotations.forEach((edgeInfo, index) => {
const edgeGeometry = new THREE.ExtrudeGeometry(edgeShape, extrudeSettings);
const edgeMaterial = new THREE.MeshBasicMaterial({ color: grayColorList[7] });
const edgeMaterial = new THREE.MeshBasicMaterial({ color: this.color });
const edge = new THREE.Mesh(edgeGeometry, edgeMaterial);
edge.position.set(...edgeInfo.pos).multiplyScalar(this.cubeSize);
edge.scale.multiplyScalar(this.cubeSize);
edge.rotation.set(...edgeInfo.rot);
this.orientationEdges!.add(edge);
edge.userData.index = index;
orientationEdges.add(edge);
// Add outline
const edgeOutline = new THREE.EdgesGeometry(edgeGeometry);
const edgeOutlineMesh = new THREE.LineSegments(
edgeOutline,
new THREE.LineBasicMaterial({ color: 0x000000 })
new THREE.LineBasicMaterial({ color: this.borderColor })
);
edgeOutlineMesh.position.copy(edge.position);
edgeOutlineMesh.scale.copy(edge.scale);
edgeOutlineMesh.rotation.copy(edge.rotation);
this.orientationEdges!.add(edgeOutlineMesh);
// pass index to mesh for later selection handling
orientationEdges.add(edgeOutlineMesh);
});
this.orientationEdges = orientationEdges;
this.scene.add(this.orientationEdges);
// Draw tringles everywhere where two edges meet
const triangleSideSize = Math.sqrt(Math.pow(bevelSize, 2) + Math.pow(bevelSize, 2));
const points = [
new Vector2(0, 0),
new Vector2(0, triangleSideSize),
new Vector2(triangleSideSize, 0)
];
const triangleShape = new THREE.Shape(points);
const triangleGeo = new THREE.ShapeGeometry(triangleShape);
const triangleMaterial = new THREE.MeshBasicMaterial({
color: 0x0edfee,
side: THREE.DoubleSide
});
const triangleMesh = new THREE.Mesh(triangleGeo, triangleMaterial);
// triangleMesh.rotation.setFromVector3(new Vector3(0, Math.PI / 2, 0));
// triangleMesh.position.set(0.5, 0.65, 0.5).multiplyScalar(cubeSize);
// triangleMesh.scale.multiplyScalar(cubeSize);
this.scene.add(triangleMesh);
// const triangleMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
// const trianglePositionsRotations = [
// // Top horizontal edges
// { pos: [0.5, 0.5, 0.5], rot: [0, -Math.PI / 2, 0] }, // Front top edge
// { pos: [-0.5, 0.5, -0.5], rot: [0, Math.PI / 2, 0] }, // Back top edge
// { pos: [-0.5, 0.5, -0.5], rot: [0, 0, Math.PI / 2] }, // Left top edge
// { pos: [0.5, 0.5, -0.5], rot: [0, 0, 0] }, // Right top edge
// // Bottom horizontal edges
// { pos: [0.5, -0.5, 0.5], rot: [0, -Math.PI / 2, -Math.PI / 2] }, // Front bottom edge
// { pos: [-0.5, -0.5, -0.5], rot: [0, Math.PI / 2, -Math.PI / 2] }, // Back bottom edge
// { pos: [-0.5, -0.5, -0.5], rot: [0, 0, Math.PI] }, // left bottom edge
// { pos: [0.5, -0.5, -0.5], rot: [0, 0, -Math.PI / 2] }, // right bottom edge
// { pos: [0.5, 0.5, 0.5], rot: [Math.PI / 2, 0, 0] }, // X/Y Edge (right,front)
// { pos: [0.5, -0.5, -0.5], rot: [-Math.PI / 2, 0, 0] }, // X/Y Edge (right,back)
// const testGeo = this.createBoxWithRoundedEdges(1, 1, 1, 0.1, 1);
// const cubeMaterial = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
// this.orientationCube = new Mesh(cubeGeometry, materials);
// const testMesh = new Mesh(testGeo, materials);
// testMesh.scale.set(cubeSize, cubeSize, cubeSize);
// this.scene.add(testMesh);
// // Edges for the impression of beveled edge
// const edges = new EdgesGeometry(testGeo);
// const line = new LineSegments(edges, new LineBasicMaterial({ color: 0x000000 }));
// line.scale.set(cubeSize, cubeSize, cubeSize);
// this.scene.add(line);
// const sidePositions = [
// new Vector3(1, 0, 1),
// new Vector3(-1, 0, 1),
// new Vector3(-1, 0, -1),
// new Vector3(1, 0, -1)
// Setup corner triangles
// // FIXME: not correct positions yet
// const triangleSideSize = Math.sqrt(Math.pow(bevelSize, 2) + Math.pow(bevelSize, 2));
// const points = [
// new Vector2(0, 0),
// new Vector2(0, triangleSideSize),
// new Vector2(triangleSideSize, 0)
// ];
// const rotationAxis = [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, 1)];
// const triangleShape = new THREE.Shape(points);
// const triangleGeo = new THREE.ShapeGeometry(triangleShape);
// for (const axis of rotationAxis) {
// const tube = new CylinderGeometry(0.5, 0.5, cubeSize, 10, 1, false);
// for (const side of sidePositions) {
// const tubeMesh = new Mesh(tube, new MeshBasicMaterial({ color: 0xff9933 }));
// tubeMesh.rotation.setFromVector3(axis.clone().multiplyScalar(Math.PI / 2));
// // Rotate side vector
// const rotatedSide = side.clone().applyAxisAngle(axis, Math.PI / 2);
// tubeMesh.position.copy(rotatedSide).multiplyScalar((cubeSize / 2) * 1.2);
// // tubeMesh.position.copy(side).multiplyScalar(cubeSize / 2);
// this.scene.add(tubeMesh);
// }
// }
// const triangleMaterial = new THREE.MeshBasicMaterial({
// color: 0x0edfee,
// side: THREE.DoubleSide
// });
// const triangleMesh = new THREE.Mesh(triangleGeo, triangleMaterial);
// // Draw pyramid on all corners
// const pyramidGeo = new SphereGeometry(1.25);
// for (const y of [1, -1]) {
// for (const side of sidePositions) {
// const pyramidMesh = new Mesh(pyramidGeo, new MeshBasicMaterial({ color: 0xffcc33 }));
// pyramidMesh.position.copy(side);
// pyramidMesh.position.y = y;
// pyramidMesh.position.multiplyScalar((cubeSize / 2) * 1.2);
// this.scene.add(pyramidMesh);
// }
// }
// side lines
// TODO: fixme render lines on top of cube
// Add light to scene
const light = new DirectionalLight(0xffffff, 1);
light.position.set(0, 0, 1);
// Setup renderer
this.renderer = new WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0x000000, 0);
this.renderer.setSize(bounds.width, bounds.height);
element.appendChild(this.renderer.domElement);
this.setupControls();
this.startAnimationLoop();
// this.scene.add(triangleMesh);
}
destroy() {
this.stopped = true;
}
private lookAtFace(cubeFaceIndex: number) {
private lookAtFaceDirection(cubeFaceIndex: number): THREE.Vector3 | null {
console.log('looking at side', cubeFaceIndex);
let lookDirection: THREE.Vector3 | null = null;
console.log('Looking at face', cubeFaceIndex);
switch (cubeFaceIndex) {
case 0:
lookDirection = new Vector3(0, 0, -1);
lookDirection = new Vector3(1, 0, 0);
break;
case 1:
lookDirection = new Vector3(-1, 0, 0);
@@ -389,19 +287,64 @@ export class Minimap {
lookDirection = new Vector3(0, 0, -1);
break;
}
if (!lookDirection || !this.trackedCamera) {
return lookDirection;
}
private lookAtEdgeDirection(edgeIndex: number) {
console.log('looking at edge', edgeIndex);
switch (edgeIndex) {
case 0:
return new Vector3(0, 1, 1);
case 1:
return new Vector3(0, 1, -1);
case 2:
return new Vector3(-1, 1, 0);
case 3:
return new Vector3(1, 1, 0);
case 4:
return new Vector3(0, -1, 1);
case 5:
return new Vector3(0, -1, -1);
case 6:
return new Vector3(-1, -1);
case 7:
return new Vector3(1, -1);
}
return null;
}
private lookAtSelection() {
if (!this.selection) {
return;
}
console.log('Looking at', lookDirection, this.trackedCamera);
let lookDirection: Vector3 | null = null;
switch (this.selection.type) {
case SelectionType.side:
lookDirection = this.lookAtFaceDirection(this.selection.index);
break;
case SelectionType.edge:
lookDirection = this.lookAtEdgeDirection(this.selection.index);
break;
case SelectionType.corner:
// FIXME: implement corner selection
console.error('Not implemented');
}
if (lookDirection === null) {
return;
}
const initialLookAt = this.camera.position.clone();
const cameraTarget = lookDirection.multiplyScalar(300);
// Compute distance between current camera position and target to compute duration
// Compute distance between current camera position and target to compute animation duration
const distance = initialLookAt.distanceTo(cameraTarget);
const duration = Math.min(200, distance * 2);
// Animate camera
new Tween(initialLookAt)
.to(cameraTarget, duration) // 2000 milliseconds
.easing(Easing.Cubic.In) // Easing type
@@ -410,94 +353,168 @@ export class Minimap {
// Called during the update of the tween. Useful if you need to perform actions during the animation.
})
.start();
// const cameraPosition = lookDirection.multiplyScalar(300);
// this.camera.position.copy(cameraPosition);
// this.camera.lookAt(lookDirection);
// Clear face selection to avoid issues
this.clearFaceSelection();
}
private colorForFaceIndex(faceIndex: number): THREE.ColorRepresentation {
return grayColorList[4 + faceIndex];
}
private clearFaceSelection() {
if (this.previousFaceIndex !== null) {
const material = this.orientationCube!.material[this.previousFaceIndex];
const oldColor =
material.userData.color ?? new Color(this.colorForFaceIndex(this.previousFaceIndex));
material.color = oldColor;
material.needsUpdate = true;
this.previousFaceIndex = null;
private handleEdgeSelection(): boolean {
if (!this.orientationEdges) {
return false;
}
}
private setFaceSelection(faceIndex: number) {
// Do nothing if already selected to avoid overwriting old color
if (this.previousFaceIndex === faceIndex) {
return;
}
const material = this.orientationCube!.material[faceIndex];
const oldColor = material.color;
if (!material.userData.color) {
material.userData.color = oldColor;
const intersections = this.raycaster.intersectObjects(this.orientationEdges.children);
if (intersections.length === 0) {
return false;
}
material.color = new Color(0xff0000);
material.needsUpdate = true;
this.previousFaceIndex = faceIndex;
}
private renderFaceSelection() {
// Check for UI interaction
if (!this.mouseInside) {
this.clearFaceSelection();
return;
}
this.raycaster.setFromCamera(this.mousePosition, this.camera);
const edgeIntersections = this.raycaster.intersectObjects(this.orientationEdges!.children);
if (edgeIntersections.length > 0) {
edgeIntersections[0].object.material.color = new Color(0xff0000);
return;
const object = intersections.find((el) => el.object.userData.index !== undefined)
?.object as THREE.Mesh;
if (!object) {
return false;
}
const intersects = this.raycaster.intersectObject(this.orientationCube!);
const index = object.userData.index;
// if selection did not change
// return selection handled but do nothing else to prevent further search
if (
intersects.length > 0 &&
intersects[0].object === this.orientationCube &&
intersects[0].faceIndex !== undefined
this.selection &&
this.selection.type === SelectionType.side &&
index === this.selection.index
) {
// convert triangle faces to cube face index
const cubeFaceIndex = Math.floor(intersects[0].faceIndex / 2);
if (this.previousFaceIndex !== cubeFaceIndex) {
this.clearFaceSelection();
}
return true;
}
this.setFaceSelection(cubeFaceIndex);
this.clearSelection();
// If we selected some other geometry ignore hit test
if (index === undefined) {
return false;
}
// Update selection
this.selection = {
type: SelectionType.edge,
index: index
};
return true;
}
private handleSideSelection(): boolean {
if (!this.orientationEdges || !this.orientationCube) {
return false;
}
const intersects = this.raycaster.intersectObject(this.orientationCube);
if (intersects.length === 0) {
return false;
}
const faceIndex = intersects[0].faceIndex;
if (faceIndex === undefined) {
return false;
}
const cubeFaceIndex = Math.floor(faceIndex / 2);
// If selection matches current element do nothing just mark event as handled
if (
this.selection &&
this.selection.type === SelectionType.side &&
this.selection.index === cubeFaceIndex
) {
return true;
}
this.clearSelection();
this.selection = {
type: SelectionType.side,
index: cubeFaceIndex
};
return true;
}
private clearSelection() {
this.applyColorToSelectedObject(this.color);
this.selection = undefined;
}
private applyColorToSelectedObject(color: THREE.Color) {
if (!this.selection) {
return;
}
if (this.previousFaceIndex !== null) {
this.clearFaceSelection();
// Find matching element and restore original material
switch (this.selection.type) {
case SelectionType.side: {
if (!this.orientationCube) {
break;
}
const material = this.orientationCube.material[this.selection.index];
material.color = color;
material.needsUpdate = true;
break;
}
case SelectionType.edge: {
if (!this.orientationEdges) {
break;
}
const mesh = this.orientationEdges.children.find(
(el) => el.userData.index === this.selection?.index
) as Mesh<THREE.ExtrudeGeometry, MeshBasicMaterial> | undefined;
if (!mesh) {
break;
}
mesh.material.color = color;
mesh.material.needsUpdate = true;
break;
}
case SelectionType.corner: {
console.error('Not implemented yet');
break;
}
}
}
private renderSelection() {
this.applyColorToSelectedObject(this.selectionColor);
}
private updateSelection() {
if (!this.mouseInside) {
this.clearSelection();
return;
}
if (this.handleEdgeSelection()) {
this.renderSelection();
return;
}
if (this.handleSideSelection()) {
this.renderSelection();
return;
}
this.clearSelection();
}
private startAnimationLoop() {
if (this.stopped) {
return;
}
if (this.trackedCamera) {
this.renderFaceSelection();
// // console.log('rendering');
// this.scene.quaternion.copy(this.trackedCamera.quaternion).conjugate();
// this.scene.up.copy(this.trackedCamera.up);
// this.renderer.render(this.scene, this.camera);
this.controls.update();
// Update raycaster but only if mouse moved
this.raycaster.setFromCamera(this.mousePosition, this.camera);
this.updateSelection();
this.renderer.render(this.scene, this.camera);
// Set target camera to match the one we're tracking
+323 -244
View File
@@ -1,56 +1,75 @@
import * as THREE from 'three';
import { GraphRenderer } from './GraphRenderer';
import { DataPlaneShapeMaterial } from './materials/DataPlaneMaterial';
import { DataPlaneShapeGeometry } from './geometry/DataPlaneGeometry';
import { graphColors } from './colors';
import { Axis, AxisRenderer, defaultAxisLabelOptions } from './AxisRenderer';
import { AxisRenderer, type AxisLabelRenderer } from './AxisRenderer';
interface IPlaneData {
points: (Float32Array | number[])[];
min: number;
max: number;
name: string;
meta?: Record<string, unknown>;
color?: string;
}
export interface IPlaneRendererData {
// A list of ordered planes (e.g. bottom to top)
// each plane is a 2D array of points
layers: {
points: (Float32Array | number[])[];
min: number;
max: number;
name: string;
meta?: Record<string, unknown>;
color?: string;
}[];
layers: IPlaneData[];
labels: {
x?: string;
y?: string;
z?: string;
};
normalized?: boolean;
scaleY?: number;
tileRange: {
x: number;
z: number;
};
ranges: {
x: [number, number];
y: [number, number];
z: [number, number];
};
}
export class PlaneRenderer extends GraphRenderer<IPlaneRendererData> {
public data?: IPlaneRendererData;
private gridHelper?: THREE.GridHelper;
private group?: THREE.Group;
private grids?: THREE.Group;
private dataDepth = 0;
private dataWidth = 0;
private layers: THREE.Group[] = [];
private scale = 2;
private planeGroup: THREE.Group = new THREE.Group();
private get planes() {
return this.planeGroup.children as THREE.Group[];
}
private min = 0;
private max = 0;
private axisLabelRenderer?: (axis: Axis, segment: number) => string;
private axisLabelRenderer?: AxisLabelRenderer;
// factor used for normalizing data into range from 0, 1 along Y axis
// 1 / [maximum Y axis value in data]
// is NaN if used before data is loaded
private get yAxisNormalizationFactor() {
if (this.max === 0) {
return NaN;
}
return 1 / this.max;
}
// Dots displayed on top of each data layer
private selectedInstanceId: number | undefined;
private selectedLayerIndex: number | undefined;
private raycaster = new THREE.Raycaster();
private axisRenderer?: AxisRenderer;
// Getter for all bar blocks managed by the renderer
get children(): THREE.Object3D[] {
return this.group?.children ?? [];
}
// override readonly type = 'PlaneRenderer';
constructor() {
super();
console.log('Setup complete');
}
onResize(evt: UIEvent): void {
@@ -59,12 +78,11 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData> {
destroy(): void {
console.log('Destroying plane renderer');
if (this.group) {
this.scene?.remove(this.group);
}
this.cleanup();
this.scene?.remove(this);
}
setAxisLabelRenderer(renderer?: (axis: Axis, segment: number) => string): void {
setAxisLabelRenderer(renderer?: AxisLabelRenderer): void {
this.axisLabelRenderer = renderer;
}
@@ -75,115 +93,174 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData> {
// renderTargetHTMLElement.addEventListener('resize', this.onResize.bind(this));
}
setScale(scale: THREE.Vector3): void {
this.size = scale;
this.group?.position.setY(-0.1 * scale.y);
this.group?.scale.copy(scale).multiplyScalar(1 / (this.scale * 2));
}
onBeforeRender = (
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
geometry: THREE.BufferGeometry<THREE.NormalBufferAttributes>,
material: THREE.Material,
group: THREE.Group
) => {
// Update axis renderer
this.axisRenderer?.onBeforeRender(renderer, scene, camera, geometry, material, group);
if (!this.grids) {
return;
}
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
const defaultNormal = new THREE.Vector3(0, 1, 0);
const grids = this.grids.children as THREE.GridHelper[];
// compute distance to camera and select 3th closest sides
const closestGrids = grids
.map((grid, idx) => [idx, grid.position.distanceTo(camera.position)])
.sort(([, a], [, b]) => a - b);
const gridsToHide = 3;
// hide two closest grids
for (let i = 0; i < grids.length; i++) {
const [idx, distance] = closestGrids[i];
const grid = grids[idx];
const material = grid.material;
let opacity = 0;
if (i >= gridsToHide) {
const gridNormal = defaultNormal.clone().transformDirection(grid.matrixWorld);
const dot = cameraDirection.dot(gridNormal);
opacity = Math.max(Math.abs(dot), 0);
}
if (Array.isArray(material)) {
material.forEach((mat) => {
mat.opacity = opacity;
mat.transparent = true;
mat.needsUpdate = true;
});
} else {
material.opacity = opacity;
material.transparent = true;
material.needsUpdate = true;
}
}
};
getIntersections(raycaster: THREE.Raycaster): THREE.Intersection[] {
const intersection = raycaster.intersectObjects(this.children, true);
if (!intersection.length) {
return [];
}
// Filter out instanced geometry
const index = intersection.findIndex(
(i) =>
i.instanceId !== undefined &&
this.layers[(i.object as THREE.InstancedMesh).userData.index].visible
);
if (index == -1) {
if (this.selectedInstanceId !== undefined) {
// Color in selected instance
this.selectedInstanceId = undefined;
this.selectedLayerIndex = undefined;
this.onDataPointSelected?.();
}
// Bypass invisible layers
return intersection.filter((i) => i.object.visible && i.object.parent.visible);
}
const instanceId = intersection[index].instanceId as number;
const mesh = intersection[index].object as THREE.InstancedMesh;
const meshIndex = mesh.userData.index;
if (this.selectedLayerIndex !== meshIndex && instanceId !== this.selectedInstanceId) {
this.selectedInstanceId = instanceId;
this.selectedLayerIndex = mesh.userData.index;
// Color in selected instance
const color = new THREE.Color(0xff00ff);
mesh.setColorAt(instanceId, color);
if (mesh.instanceColor) {
mesh.instanceColor.needsUpdate = true;
}
if (this.onDataPointSelected) {
const point = new THREE.Vector3(
this.selectedInstanceId % this.dataDepth,
this.selectedLayerIndex,
Math.floor(this.selectedInstanceId / this.dataWidth)
);
const value = this.data?.layers[meshIndex].points[point.z][point.x];
console.log(value);
this.onDataPointSelected(point, {
value,
layer: this.data?.layers[meshIndex],
layerIndex: meshIndex,
instanceId
});
}
}
return [];
// const intersection = raycaster.intersectObjects(this.children, true);
// if (!intersection.length) {
// return [];
// }
// // Filter out instanced geometry
// const index = intersection.findIndex(
// (i) =>
// i.instanceId !== undefined &&
// this.layers[(i.object as THREE.InstancedMesh).userData.index].visible
// );
// if (index == -1) {
// if (this.selectedInstanceId !== undefined) {
// // Color in selected instance
// this.selectedInstanceId = undefined;
// this.selectedLayerIndex = undefined;
// this.onDataPointSelected?.();
// }
// // Bypass invisible layers
// return intersection.filter((i) => i.object.visible && i.object.parent.visible);
// }
// const instanceId = intersection[index].instanceId as number;
// const mesh = intersection[index].object as THREE.InstancedMesh;
// const meshIndex = mesh.userData.index;
// if (this.selectedLayerIndex !== meshIndex && instanceId !== this.selectedInstanceId) {
// this.selectedInstanceId = instanceId;
// this.selectedLayerIndex = mesh.userData.index;
// // Color in selected instance
// const color = new THREE.Color(0xff00ff);
// mesh.setColorAt(instanceId, color);
// if (mesh.instanceColor) {
// mesh.instanceColor.needsUpdate = true;
// }
// if (this.onDataPointSelected) {
// const point = new THREE.Vector3(
// this.selectedInstanceId % this.dataDepth,
// this.selectedLayerIndex,
// Math.floor(this.selectedInstanceId / this.dataWidth)
// );
// const value = this.data?.layers[meshIndex].points[point.z][point.x];
// console.log(value);
// this.onDataPointSelected(point, {
// value,
// layer: this.data?.layers[meshIndex],
// layerIndex: meshIndex,
// instanceId
// });
// }
// }
// return [];
}
cleanup(): void {
// Remove all previous layers
this.layers.forEach((layer) => {
this.group?.remove(layer);
layer.clear();
});
private renderPlane(planeData: IPlaneData, index: number, color: THREE.Color) {
const plane = planeData.points;
const geo = new DataPlaneShapeGeometry(plane, undefined, true);
// Remove axis renderer
if (this.axisRenderer) {
this.axisRenderer.destroy();
this.axisRenderer = undefined;
const mat = new THREE.MeshLambertMaterial({
color: color,
opacity: 1,
depthWrite: true,
// clipIntersection: true,
// clipShadows: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
// Add metadata to mesh
mesh.userData = { index, name: planeData.name, meta: planeData.meta };
this.dataDepth = geo.planeDims.depth;
this.dataWidth = geo.planeDims.width;
return mesh;
}
private renderPlaneDots(
layerGeometry: DataPlaneShapeGeometry,
index: number,
color: THREE.ColorRepresentation = 0xeeeeff
): THREE.InstancedMesh {
const pointBuffer = layerGeometry.buffer;
if (!pointBuffer) {
throw new Error(
'Cannot render layer dots without previously buffered DataPlaneShapeGeometry'
);
}
if (this.group) {
this.scene?.remove(this.group);
this.group.clear();
const sphereGeo = new THREE.SphereGeometry(0.008);
const sphereMat = new THREE.MeshBasicMaterial({ color: color, depthWrite: false });
const dotMesh = new THREE.InstancedMesh(sphereGeo, sphereMat, layerGeometry.pointsPerPlane);
dotMesh.userData = { index };
// dotMesh.renderOrder = 10;
dotMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// Set position of each dot
const matrix = new THREE.Matrix4();
const yAxisScaleFactor = this.yAxisNormalizationFactor;
for (let i = 0; i < layerGeometry.pointsPerPlane; i++) {
const idx = i * DataPlaneShapeGeometry.pointComponentSize;
// Apply scale
matrix.setPosition(
pointBuffer[idx],
pointBuffer[idx + 1] * yAxisScaleFactor,
pointBuffer[idx + 2]
);
dotMesh.setMatrixAt(i, matrix);
}
}
toggleLayerVisibility(layerIndex: number): boolean {
const layer = this.layers[layerIndex];
layer.visible = !layer.visible;
dotMesh.instanceMatrix.needsUpdate = true;
dotMesh.computeBoundingSphere();
return layer.visible;
}
getLayerVisibility(): boolean[] {
return this.layers.map((layer) => layer.visible);
}
showAllLayers(): void {
this.layers.forEach((layer) => {
layer.visible = true;
});
}
hideAllLayers(): void {
this.layers.forEach((layer) => {
layer.visible = false;
});
return dotMesh;
}
updateWithData(
@@ -195,163 +272,137 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData> {
console.warn('No data provided');
return;
}
this.cleanup();
const group = new THREE.Group();
const sphereGeo = new THREE.SphereGeometry(0.008);
this.planeGroup = new THREE.Group();
this.data = data;
let globalMin = Infinity;
let globalMax = -Infinity;
this.layers = data.layers.map((layer, index) => {
console.log('Layer:', layer.name, 'Min:', layer.min, 'Max:', layer.max);
globalMax = Math.max(globalMax, layer.max);
globalMin = Math.min(globalMin, layer.min);
const meshes = data.layers.map((planeData, index) => {
globalMax = Math.max(globalMax, planeData.max);
globalMin = Math.min(globalMin, planeData.min);
const color = new THREE.Color(planeData.color ?? colorPalette[index % colorPalette.length]);
const planeMesh = this.renderPlane(planeData, index, color);
const plane = layer.points;
const layerGroup = new THREE.Group();
const geo = new DataPlaneShapeGeometry(plane, undefined, true);
const color = new THREE.Color(layer.color ?? colorPalette[index % colorPalette.length]);
const mat = new THREE.MeshLambertMaterial({
color: color,
opacity: 1,
depthWrite: true,
// clipIntersection: true,
// clipShadows: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
layerGroup.add(mesh);
// Add metadata to mesh
mesh.userData = { index, name: layer.name, meta: layer.meta };
this.dataDepth = geo.planeDims.depth;
this.dataWidth = geo.planeDims.width;
group.add(layerGroup);
return layerGroup;
return planeMesh;
});
let dataScaleFactor = globalMax - globalMin;
dataScaleFactor = dataScaleFactor === 0 ? 1 : dataScaleFactor;
dataScaleFactor = 1 / dataScaleFactor;
// With the scale known we can now draw the layer points
for (const [index, layerGroup] of this.layers.entries()) {
const geo = (layerGroup.children[0] as THREE.Mesh).geometry as DataPlaneShapeGeometry;
if (!geo) {
continue;
}
const pointBuffer = geo.buffer;
if (!pointBuffer) {
continue;
}
const sphereMat = new THREE.MeshBasicMaterial({ color: 0xeeeeff, depthWrite: false });
const dotMesh = new THREE.InstancedMesh(sphereGeo, sphereMat, geo.pointsPerPlane);
dotMesh.userData = { index };
// dotMesh.renderOrder = 10;
dotMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// Set position of each dot
const matrix = new THREE.Matrix4();
for (let i = 0; i < geo.pointsPerPlane; i++) {
const idx = i * DataPlaneShapeGeometry.pointComponentSize;
// Apply scale
matrix.setPosition(
pointBuffer[idx],
pointBuffer[idx + 1] * dataScaleFactor,
pointBuffer[idx + 2]
);
dotMesh.setMatrixAt(i, matrix);
}
dotMesh.instanceMatrix.needsUpdate = true;
dotMesh.computeBoundingSphere();
// Scale data
layerGroup.children[0].scale.y = dataScaleFactor;
layerGroup.add(dotMesh);
// Move layer group by tine amount to avoid z-fighting
// layerGroup.position.y = index * 0.00001;
}
this.min = globalMin;
this.max = globalMax;
console.log('Min:', this.min, 'Max:', this.max);
const dataScaleFactor = 1 / globalMax;
// const geometry = new DataPlaneShapeGeometry(highestValues, undefined);
// Render dots and scale layers
const dotMeshes = meshes.map((planeMesh, index) => {
const geo = planeMesh.geometry as DataPlaneShapeGeometry;
if (!geo) {
throw Error('Plane mesh be initialized before dot geometry can be created');
}
// // Create a material with the custom fragment shader
// const material = new DataPlaneShapeMaterial(new THREE.Color(0xff00ff));
return this.renderPlaneDots(geo, index);
});
// const mesh = new THREE.Mesh(geometry, material);
// group.add(mesh);
// Combine meshes and dotmeshes to create layer groups
for (const [i, mesh] of meshes.entries()) {
const group = new THREE.Group();
// set scaling
// - only scale layers
mesh.scale.y = dataScaleFactor;
group.add(mesh);
group.add(dotMeshes[i]);
this.planeGroup.add(group);
}
// Move plane group to be centered at 0,0,0
this.planeGroup.position.set(-0.5, 0, -0.5);
this.add(this.planeGroup);
this.group = group;
this.setupGridHelper();
this.setupAxisRenderer();
this.setScale(this.size);
this.scene?.add(group);
// this.setScale(this.scale);
}
cleanup(): void {
// Remove axis renderer
if (this.axisRenderer) {
this.axisRenderer.destroy();
this.axisRenderer = undefined;
}
this.planeGroup.clear();
this.grids?.clear();
this.clear();
}
////////////////////////////////
// PlaneRenderer specific methods
/////////////////////////////////
toggleLayerVisibility(layerIndex: number): boolean {
const layer = this.planes[layerIndex];
layer.visible = !layer.visible;
return layer.visible;
}
getLayerVisibility(): boolean[] {
return this.planes.map((plane) => plane.visible);
}
showAllLayers(): void {
this.planes.forEach((plane) => {
plane.visible = true;
});
}
hideAllLayers(): void {
this.planes.forEach((plane) => {
plane.visible = false;
});
}
////////////////////////////////
// Private helpers
/////////////////////////////////
private setupAxisRenderer() {
this.axisRenderer = new AxisRenderer({
// labelScale: 10,
size: new THREE.Vector3(2, 2, 2),
labelScale: 0.15,
size: new THREE.Vector3(1, 1, 1),
labelScale: 0.075,
labelForSegment: this.axisLabelRenderer,
x: {
label: { text: this.data?.labels?.x ?? 'x' },
segments: this.dataWidth - 1,
labelForSegment: this.axisLabelRenderer
? (segment: number) => this.axisLabelRenderer?.(Axis.Y, segment)
: undefined
labelText: this.data?.labels?.x ?? 'x',
segments: this.dataWidth - 1
},
y: {
label: { text: this.data?.labels?.y ?? 'y' },
segments: 100,
labelForSegment: this.axisLabelRenderer
? (segment: number) => this.axisLabelRenderer?.(Axis.Y, segment)
: undefined
labelText: this.data?.labels?.y ?? 'y',
segments: 10
},
z: {
label: { text: this.data?.labels?.z ?? 'z' },
segments: this.dataDepth - 1,
labelForSegment: this.axisLabelRenderer
? (segment: number) => this.axisLabelRenderer?.(Axis.Z, segment)
: undefined
labelText: this.data?.labels?.z ?? 'z',
segments: this.dataDepth - 1
}
});
this.axisRenderer.position.x = -1;
this.axisRenderer.position.z = -1;
this.group?.add(this.axisRenderer);
// center axis ar (0,0,0)
this.axisRenderer.position.set(-0.5, 0, -0.5);
this.add(this.axisRenderer);
}
private setupGridHelper() {
const baseScale = 2;
const overlapFactor = 1;
private createGrid(baseScale = 1, overlapFactor = 1) {
const numWidthTiles = this.dataWidth - 1;
const numDepthTiles = this.dataDepth - 1;
const isWidthSmaller = numWidthTiles < numDepthTiles;
const largerSide = isWidthSmaller ? numDepthTiles : numWidthTiles;
this.gridHelper = new THREE.GridHelper(
const gridHelper = new THREE.GridHelper(
baseScale * overlapFactor,
largerSide * overlapFactor,
0x888888,
@@ -366,17 +417,45 @@ export class PlaneRenderer extends GraphRenderer<IPlaneRendererData> {
if (isWidthSmaller) {
const zSegmentSize = baseScale / largerSide / 2;
const xSegmentSize = zSegmentSize * (numDepthTiles / numWidthTiles);
this.gridHelper.scale.x = numDepthTiles / numWidthTiles;
gridHelper.scale.x = numDepthTiles / numWidthTiles;
// this.gridHelper.position.x = -xSegmentSize;
// this.gridHelper.position.z = -zSegmentSize;
} else {
const xSegmentSize = baseScale / largerSide;
const zSegmentSize = xSegmentSize * (numWidthTiles / numDepthTiles);
this.gridHelper.scale.z = numWidthTiles / numDepthTiles;
gridHelper.scale.z = numWidthTiles / numDepthTiles;
// this.gridHelper.position.z = -zSegmentSize;
// this.gridHelper.position.x = -xSegmentSize;
}
this.group?.add(this.gridHelper);
return gridHelper;
}
private setupGridHelper() {
if (this.grids) {
this.grids.clear();
} else {
this.grids = new THREE.Group();
}
// Draw a grid for each side
const orientations = [
[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 1, 0)],
[new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)],
[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0.5, -0.5)],
[new THREE.Vector3(0, 0, 1), new THREE.Vector3(-0.5, 0.5, 0)],
[new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 0.5, 0.5)],
[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0.5, 0.5, 0)]
];
for (const [orientation, offset] of orientations) {
const grid = this.createGrid();
grid.setRotationFromAxisAngle(orientation, Math.PI / 2);
grid.position.set(offset.x, offset.y, offset.z);
this.grids.add(grid);
}
this.add(this.grids);
}
}
+26 -39
View File
@@ -32,7 +32,7 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
data: Data,
previousData: Data | undefined = undefined,
normalized = false,
interpolateZeroes = false,
interpolateZeroes = true,
private drawsSideWalls = false,
private drawsBottom = false
) {
@@ -103,41 +103,29 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
}
interpolate(matrix: Data, z: number, x: number): number | null {
let sum = 0;
let count = 0;
let radius = 1;
while (true) {
const zMin = Math.max(z - radius, 0);
const zMax = Math.min(z + radius, matrix.length - 1);
const xMin = Math.max(x - radius, 0);
const xMax = Math.min(x + radius, matrix[z].length - 1);
let allZero = true;
for (let zi = zMin; zi <= zMax; zi++) {
for (let xi = xMin; xi <= xMax; xi++) {
// Only consider boundary values of the current radius
if (zi === z - radius || zi === z + radius || xi === x - radius || xi === x + radius) {
const val = matrix[zi][xi];
sum += val;
count++;
if (val !== 0) {
allZero = false;
}
}
}
}
if (!allZero || radius >= Math.max(matrix.length, matrix[0].length)) {
break;
}
radius++;
// Check for next non zero in row, col direction and diagonal
if (z < 1 || x < 1 || z >= matrix.length - 1 || x >= matrix[z].length - 1) {
return matrix[z][x];
}
if (count === 0) return null;
return sum / count;
const y = matrix[z][x];
if (y !== 0) {
return y;
}
if (matrix[z - 1][x - 1] !== 0 && matrix[z + 1][x + 1] !== 0) {
return (matrix[z - 1][x - 1] + matrix[z + 1][x + 1]) / 2;
}
if (matrix[z - 1][x] !== 0 && matrix[z + 1][x] !== 0) {
return (matrix[z - 1][x] + matrix[z + 1][x]) / 2;
}
if (matrix[z][x - 1] !== 0 && matrix[z][x + 1] !== 0) {
return (matrix[z][x - 1] + matrix[z][x + 1]) / 2;
}
return null;
}
/**
@@ -201,18 +189,17 @@ export class DataPlaneShapeGeometry extends THREE.BufferGeometry {
const indexIdx = (z * (width - 1) + x) * 6;
// Add top plane coordinates
vertices[vertexIdx] = (x / (width - 1)) * 2.0 - 1.0; //x
vertices[vertexIdx] = x / (width - 1); //x
vertices[vertexIdx + 1] = normalizedData[x][z]; //y
vertices[vertexIdx + 2] = (z / (depth - 1)) * 2.0 - 1.0; //z
vertices[vertexIdx + 2] = z / (depth - 1); //z
if (this.drawsBottom) {
// Add bottom plane coordinates
vertices[vertexIdx + pointsPerPlane * bufferElementSize] = (x / (width - 1)) * 2.0 - 1.0; //x
vertices[vertexIdx + pointsPerPlane * bufferElementSize] = x / (width - 1); //x
vertices[vertexIdx + pointsPerPlane * bufferElementSize + 1] = hasBottomLayer
? this.previousNormalizedData?.[z][x] ?? 0 // syntax enforced by strict null checks (should never happen)
: 0;
vertices[vertexIdx + pointsPerPlane * bufferElementSize + 2] =
(z / (depth - 1)) * 2.0 - 1.0; //z
vertices[vertexIdx + pointsPerPlane * bufferElementSize + 2] = z / (depth - 1); //z
}
if (z === depth - 1 || x === width - 1) {
+61
View File
@@ -0,0 +1,61 @@
import * as THREE from 'three';
export interface TextTextureOptions {
color: THREE.ColorRepresentation;
font: string;
fontSize: number;
fontLineHeight: number;
}
export class TextTexture extends THREE.CanvasTexture {
private canvas?: HTMLCanvasElement;
private context?: CanvasRenderingContext2D;
constructor(text: string, options: TextTextureOptions) {
// Create a canvas element
const canvas = document.createElement('canvas');
canvas.width = text.length * options.fontSize;
canvas.height = options.fontSize * options.fontLineHeight;
// Get the 2D rendering context of the canvas
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to create canvas context');
}
// Set the font properties
context.font = `${options.fontSize}px ${options.font}`;
// Set the text color
context.fillStyle = new THREE.Color(options.color).getStyle();
// Set the text alignment and baseline
context.textAlign = 'center';
context.textBaseline = 'middle';
// Calculate the text position in the center of the canvas
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const textX = canvasWidth / 2;
const textY = canvasHeight / 2;
// Render the text on the canvas
context.fillText(text, textX, textY);
// Create a texture from the canvas
super(canvas);
this.canvas = canvas;
this.context = context;
// TODO: maybe reuse canvas if we update the labels frequently
// Remove the canvas from the DOM
// document.removeChild(textCanvas);
}
dispose(): void {
this.canvas?.remove();
super.dispose();
}
}
+38 -10
View File
@@ -1,4 +1,6 @@
import { get, writable } from 'svelte/store';
import { withLogMiddleware } from './logMiddleware';
import { browser } from '$app/environment';
export enum Theme {
Light = 'light',
@@ -16,16 +18,42 @@ const initialAppSettings: AppSettings = {
theme: Theme.Light
};
export const settingsStore = writable<AppSettings>(initialAppSettings);
const settingsStore = () => {
let initialState: AppSettings = { ...initialAppSettings };
export const updateTheme = (theme: Theme) => {
settingsStore.update((settings) => {
settings.theme = theme;
return settings;
});
if (browser) {
let initialTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? Theme.Dark
: Theme.Light;
const storedTheme = sessionStorage.getItem('theme');
if (storedTheme) {
initialTheme = storedTheme === Theme.Dark ? Theme.Dark : Theme.Light;
}
initialState = { ...initialAppSettings, theme: initialTheme };
}
const store = withLogMiddleware(writable<AppSettings>(initialState), 'SettingsStore');
const updateTheme = (theme: Theme) => {
store.update((settings) => {
settings.theme = theme;
return settings;
});
sessionStorage.theme = theme;
};
const toggleThemeMode = () => {
const { theme } = get(store);
updateTheme(theme === Theme.Dark ? Theme.Light : Theme.Dark);
};
return {
...store,
updateTheme,
toggleThemeMode
};
};
export const toggleThemeMode = () => {
const { theme } = get(settingsStore);
updateTheme(theme === Theme.Dark ? Theme.Light : Theme.Dark);
};
export default settingsStore();
+33 -60
View File
@@ -74,7 +74,9 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
scale,
columnName,
'"'
)}) AS min, MAX(${getSqlScaleWrapper(scale, columnName, '"')}) AS max FROM ${tableName}`;
)}) AS min, MAX(${getSqlScaleWrapper(scale, columnName, '"')}) AS max FROM ${tableName}
${scale === DataScaling.LOG ? `WHERE "${columnName}" >= 0` : ''}
`;
const resp = await store.executeQuery(query);
if (!resp) {
@@ -89,11 +91,6 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
return [rows[0].min, rows[0].max];
};
const getMultiTableColumnRange = async (
tableNames: string[],
columnName: string,
)
const getTiledRows = async (
tableName: string,
options: ITiledDataOptions,
@@ -107,63 +104,34 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
const zTileCount = options.zTileCount ?? options.tileCount;
// Compute ranges for each axis
const [xMin, xMax] = xRange ?? (await getMinMax(tableName, options.xColumnName, options.scaleX));
const [yMin, yMax] = yRange ?? (await getMinMax(tableName, options.yColumnName, options.scaleY));
const [zMin, zMax] = zRange ?? (await getMinMax(tableName, options.zColumnName, options.scaleZ));
const [xMin, xMax] =
xRange ?? (await getMinMax(tableName, options.xColumnName, options.scaleX));
const [zMin, zMax] =
zRange ?? (await getMinMax(tableName, options.zColumnName, options.scaleZ));
// Compute bucket aggregation sizes
const xBucketSize = (xMax - xMin) / xTileCount;
const zBucketSize = (zMax - zMin) / zTileCount;
const xColValue = getSqlScaleWrapper(options.scaleX, options.xColumnName, '"');
const zColValue = getSqlScaleWrapper(options.scaleZ, options.zColumnName, '"');
const yColValue = getSqlScaleWrapper(options.scaleY, options.yColumnName, '"');
// FIXME: this currently incorrectly pairs up x and z values within a mode/group
const query = `WITH
"${options.xColumnName}_min_max_x" AS (
SELECT MIN(${getSqlScaleWrapper(options.scaleX, options.xColumnName, '"')}) AS "min_${
options.xColumnName
}_x", MAX(${getSqlScaleWrapper(options.scaleX, options.xColumnName, '"')}) AS "max_${
options.xColumnName
}_x"
FROM "${tableName}"
),
"${options.zColumnName}_min_max_z" AS (
SELECT MIN(${getSqlScaleWrapper(options.scaleZ, options.zColumnName, '"')}) AS "min_${
options.zColumnName
}_z", MAX(${getSqlScaleWrapper(options.scaleZ, options.zColumnName, '"')}) AS "max_${
options.zColumnName
}_z"
FROM "${tableName}"
),
"${options.xColumnName}_bucket_sizes_x" AS (
SELECT ("max_${options.xColumnName}_x" - "min_${options.xColumnName}_x") / ${xTileCount} AS "${
options.xColumnName
}_bucket_size_x"
FROM "${options.xColumnName}_min_max_x"
),
"${options.zColumnName}_bucket_sizes_z" AS (
SELECT ("max_${options.zColumnName}_z" - "min_${options.zColumnName}_z") / ${zTileCount} AS "${
options.zColumnName
}_bucket_size_z"
FROM "${options.zColumnName}_min_max_z"
)
SELECT ${groupBy ? 'mode,' : ''}
"${options.zColumnName}", "${options.xColumnName}",
${getSqlScaleWrapper(options.scaleY, `${tileAggregationMode}("${options.yColumnName}")`)} AS y,
FLOOR((${getSqlScaleWrapper(options.scaleX, options.xColumnName, '"')} - "min_${
options.xColumnName
}_x") / "${options.xColumnName}_bucket_size_x") AS x,
FLOOR((${getSqlScaleWrapper(options.scaleZ, options.zColumnName, '"')} - "min_${
options.zColumnName
}_z") / "${options.zColumnName}_bucket_size_z") AS z,
MIN(${getSqlScaleWrapper(options.scaleX, options.xColumnName, '"')}) as "min_${
options.xColumnName
}_x",
MIN(${getSqlScaleWrapper(options.scaleZ, options.zColumnName, '"')}) as "min_${
options.zColumnName
}_z"
const query = `
SELECT
${groupBy ? 'mode,' : ''}
${tileAggregationMode}(${yColValue}) AS y,
FLOOR((${xColValue} - ${xMin}) / ${xBucketSize}) AS x,
FLOOR((${zColValue} - ${zMin}) / ${zBucketSize}) AS z
FROM "${tableName}"
CROSS JOIN "${options.xColumnName}_min_max_x"
CROSS JOIN "${options.xColumnName}_bucket_sizes_x"
CROSS JOIN "${options.zColumnName}_min_max_z"
CROSS JOIN "${options.zColumnName}_bucket_sizes_z"
${groupBy ? `WHERE mode = '${groupBy}'` : ''}
GROUP BY ${groupBy ? 'mode,' : ''} z, x, name, "${options.zColumnName}", "${options.xColumnName}"
ORDER BY z ASC, x ASC`;
${options.scaleY === DataScaling.LOG ? `WHERE "${options.yColumnName}" >= 0` : ''}
GROUP BY ${groupBy ? 'mode,' : ''} z, x
ORDER BY z ASC, x ASC;
`;
// GROUP BY ${groupBy ? 'mode,' : ''} z, x, name, "${options.zColumnName}", "${options.xColumnName}"
// ORDER BY z ASC, x ASC;
try {
const resp = await store.executeQuery(query);
@@ -197,7 +165,9 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
};
try {
const rows = await getTiledRows(tableName, options, 'min', groupBy, xRange, yRange, zRange);
const rows = await getTiledRows(tableName, options, 'max', groupBy, xRange, yRange, zRange);
console.log('Options', options);
// Transform rows into a 2D array for display
const data = Array.from(
@@ -209,6 +179,9 @@ export const dataStoreFilterExtension = (store: BaseStoreType) => {
let max = Number.MIN_VALUE;
rows.forEach((r) => {
if (r.x < 0 || r.z < 0 || Number.isNaN(r.y)) {
return;
}
min = Math.min(min, r.y);
max = Math.max(max, r.y);
data[r.x][r.z] = r.y;
+27 -1
View File
@@ -285,7 +285,7 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
// Check if references are already loaded
// FIXME: for not assume we are reloading same samples and skip
// TODO: add check or delete databases
// TODO: add check or te databases
if (Object.keys(grouped).every((tableName) => !!get(dataStore).tables[tableName])) {
console.log('All tables are already loaded, skipping');
return;
@@ -365,6 +365,32 @@ export const dataStoreLoadExtension = (store: BaseStoreType, dataStore: Writable
});
};
const removeTable = async (tableName: string) => {
const { sharedConnection: conn } = get(dataStore);
if (!conn) {
return;
}
const tables = await store.getTables();
const table = tables.find((t) => t === tableName);
if (!table) {
return;
}
try {
await store.executeQuery(`DROP TABLE "${tableName}"`);
computeCombinedTableSchema();
} catch (e) {
notificationStore.addNotification({
id: Date.now(),
type: 'error',
message: `Could not delete table ${tableName}`
});
}
};
// Export public API
return {
+12 -5
View File
@@ -221,15 +221,15 @@ export class PlaneGraphOptions extends GraphOptions<
const xAxisRange = xAxisMinMax.reduce(
(acc, [min, max]) => [Math.min(acc[0], min), Math.max(acc[1], max)],
[Infinity, -Infinity]
[0, -Infinity]
);
const yAxisRange = yAxisMinMax.reduce(
(acc, [min, max]) => [Math.min(acc[0], min), Math.max(acc[1], max)],
[Infinity, -Infinity]
[0, -Infinity]
);
const zAxisRange = zAxisMinMax.reduce(
(acc, [min, max]) => [Math.min(acc[0], min), Math.max(acc[1], max)],
[Infinity, -Infinity]
[0, -Infinity]
);
console.log('Z axis min/max', zAxisRange);
@@ -267,8 +267,15 @@ export class PlaneGraphOptions extends GraphOptions<
y: state.yColumnName,
z: state.zColumnName
},
normalized: false,
scaleY: 10
ranges: {
x: xAxisRange,
y: yAxisRange,
z: zAxisRange
},
tileRange: {
x: state.xTileCount ?? state.tileCount,
z: state.zTileCount ?? state.tileCount
}
});
} catch (e) {
console.error('Failed to load tiled data:', e);
+11 -10
View File
@@ -2,34 +2,35 @@
import { onMount } from 'svelte';
import '@fontsource/inter';
import '../app.css';
import { Theme, settingsStore } from '$lib/store/SettingsStore';
import settingsStore, { Theme } from '$lib/store/SettingsStore';
import notificationStore from '$lib/store/notificationStore';
import Button from '$lib/components/button/Button.svelte';
import { XIcon } from 'svelte-feather-icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '$lib/components/button/type';
import { browser } from '$app/environment';
function setDarkMode(enabled: boolean) {
if (enabled) {
document.documentElement.classList.add('dark');
localStorage.theme = Theme.Dark;
} else {
document.documentElement.classList.remove('dark');
localStorage.theme = Theme.Light;
}
}
onMount(() => {
const initialDark =
localStorage.theme === Theme.Dark ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
setDarkMode(initialDark);
$settingsStore.theme = initialDark ? Theme.Dark : Theme.Light;
// No theme setup on server side
if (!browser) {
return;
}
// Monitor browser preference for dark mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const newColorScheme = e.matches ? Theme.Dark : Theme.Light;
$settingsStore.theme = newColorScheme;
setDarkMode(newColorScheme === Theme.Dark);
settingsStore.updateTheme(newColorScheme);
});
settingsStore.subscribe((store) => {
setDarkMode(store.theme === Theme.Dark);
});
});
</script>
-7
View File
@@ -73,13 +73,6 @@
{#if $filterStore.selectedTables.length !== 0}
<FilterSidebar />
{/if}
<div class="fixed bottom-5 left-5">
<Dialog size={'large'}>
<Button slot="trigger" color={ButtonColor.PRIMARY} size={ButtonSize.LG}>SQL Editor</Button>
<svelte:fragment slot="title">SQL Query Editor</svelte:fragment>
<QueryEditor />
</Dialog>
</div>
{#if $filterStore.selectedPoint && hoverPosition}
<div
class="absolute pointer-events-none"