mirror of
https://github.com/gosticks/partition-filter-visualization.git
synced 2026-08-11 20:30:23 +00:00
wip: refactor layout and rendering path
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
<hr class="mt-4 mb-2 dark:border-background-800" />
|
||||
<hr class="mt-4 mb-2 w-full dark:border-background-950" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user