440 lines
10 KiB
Svelte
440 lines
10 KiB
Svelte
<script lang="ts">
|
|
import UpObject from './display/UpObject.svelte';
|
|
import api from '$lib/api';
|
|
import Selector, { type SelectorValue } from './utils/Selector.svelte';
|
|
import { createEventDispatcher, onMount, tick } from 'svelte';
|
|
import type { ZoomBehavior, ZoomTransform, Selection } from 'd3';
|
|
import Spinner from './utils/Spinner.svelte';
|
|
import UpObjectCard from './display/UpObjectCard.svelte';
|
|
import BlobPreview from './display/BlobPreview.svelte';
|
|
import SurfacePoint from './display/SurfacePoint.svelte';
|
|
import { i18n } from '../i18n';
|
|
import debug from 'debug';
|
|
import { Query } from '@upnd/upend';
|
|
import { Any } from '@upnd/upend/query';
|
|
const dbg = debug('kestrel:surface');
|
|
const dispatch = createEventDispatcher();
|
|
|
|
export let x: string | undefined = undefined;
|
|
export let y: string | undefined = undefined;
|
|
$: dispatch('updateAddress', { x, y });
|
|
|
|
let loaded = false;
|
|
|
|
let viewMode = 'point';
|
|
|
|
let currentX = NaN;
|
|
let currentY = NaN;
|
|
|
|
let zoom: ZoomBehavior<Element, unknown> | undefined;
|
|
let autofit: () => void | undefined;
|
|
|
|
let view: Selection<HTMLElement, unknown, null, undefined>;
|
|
let viewEl: HTMLElement | undefined;
|
|
let viewHeight = 0;
|
|
let viewWidth = 0;
|
|
|
|
let selector: Selector | undefined;
|
|
|
|
$: if (selector) selector.focus();
|
|
|
|
$: {
|
|
if ((x && !y) || (!x && y)) findPerpendicular();
|
|
}
|
|
async function findPerpendicular() {
|
|
const presentAxis = x || y;
|
|
const presentAxisAddress = await api.componentsToAddress({
|
|
t: 'Attribute',
|
|
c: presentAxis
|
|
});
|
|
const result = await api.query(
|
|
Query.or(
|
|
Query.matches(`@${presentAxisAddress}`, 'PERPENDICULAR', Any),
|
|
Query.matches(Any, 'PERPENDICULAR', `@${presentAxisAddress}`)
|
|
)
|
|
);
|
|
const perpendicular = [
|
|
...result.entries.map((e) => e.entity),
|
|
...result.values.filter((v) => v.t === 'Address').map((v) => v.c as string)
|
|
].find((address) => address !== presentAxisAddress);
|
|
|
|
if (perpendicular) {
|
|
const perpendicularComponents = await api.addressToComponents(perpendicular);
|
|
if (perpendicularComponents.t !== 'Attribute') return;
|
|
const perpendicularName = perpendicularComponents.c;
|
|
|
|
if (x) {
|
|
y = perpendicularName;
|
|
} else {
|
|
x = perpendicularName;
|
|
}
|
|
}
|
|
}
|
|
|
|
interface IPoint {
|
|
address: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
let points: IPoint[] = [];
|
|
async function loadPoints() {
|
|
points = [];
|
|
const result = await api.query(`(matches ? (in "${x}" "${y}") ?)`);
|
|
|
|
points = Object.entries(result.objects)
|
|
.map(([address, obj]) => {
|
|
let objX = parseInt(String(obj.get(x)));
|
|
let objY = parseInt(String(obj.get(y)));
|
|
|
|
if (objX && objY) {
|
|
return {
|
|
address,
|
|
x: objX,
|
|
y: objY
|
|
};
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
|
|
tick().then(() => {
|
|
autofit();
|
|
});
|
|
}
|
|
|
|
$: {
|
|
if (x && y) {
|
|
loadPoints();
|
|
}
|
|
}
|
|
|
|
let selectorCoords: [number, number] | null = null;
|
|
|
|
onMount(async () => {
|
|
const d3 = await import('d3');
|
|
|
|
function init() {
|
|
viewWidth = viewEl.clientWidth;
|
|
viewHeight = viewEl.clientHeight;
|
|
|
|
dbg('Initializing Surface view: %dx%d', viewWidth, viewHeight);
|
|
view = d3.select(viewEl);
|
|
const svg = view.select('svg');
|
|
if (svg.empty()) {
|
|
throw new Error("Failed initializing Surface - couldn't locate SVG element");
|
|
}
|
|
svg.selectAll('*').remove();
|
|
|
|
const xScale = d3.scaleLinear().domain([0, viewWidth]).range([0, viewWidth]);
|
|
|
|
const yScale = d3.scaleLinear().domain([0, viewHeight]).range([viewHeight, 0]);
|
|
|
|
let xTicks = 10;
|
|
let yTicks = viewHeight / (viewWidth / xTicks);
|
|
|
|
const xAxis = d3
|
|
.axisBottom(xScale)
|
|
.ticks(xTicks)
|
|
.tickSize(viewHeight)
|
|
.tickPadding(5 - viewHeight);
|
|
|
|
const yAxis = d3
|
|
.axisRight(yScale)
|
|
.ticks(yTicks)
|
|
.tickSize(viewWidth)
|
|
.tickPadding(5 - viewWidth);
|
|
|
|
const gX = svg.append('g').call(xAxis);
|
|
const gY = svg.append('g').call(yAxis);
|
|
|
|
zoom = d3.zoom().on('zoom', zoomed);
|
|
|
|
function zoomed({ transform }: { transform: ZoomTransform }) {
|
|
const points = view.select('.content');
|
|
points.style(
|
|
'transform',
|
|
`translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`
|
|
);
|
|
const allPoints = view.selectAll('.point');
|
|
allPoints.style('transform', `scale(${1 / transform.k})`);
|
|
|
|
gX.call(xAxis.scale(transform.rescaleX(xScale)));
|
|
gY.call(yAxis.scale(transform.rescaleY(yScale)));
|
|
|
|
updateStyles();
|
|
}
|
|
|
|
autofit = () => {
|
|
zoom.translateTo(view, 0, viewHeight);
|
|
|
|
if (points.length) {
|
|
zoom.scaleTo(
|
|
view,
|
|
Math.min(
|
|
viewWidth / 2 / Math.max(...points.map((p) => Math.abs(p.x))) - 0.3,
|
|
viewHeight / 2 / Math.max(...points.map((p) => Math.abs(p.y))) - 0.3
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
function updateStyles() {
|
|
svg
|
|
.selectAll('.tick line')
|
|
.attr('stroke-width', (d: number) => {
|
|
return d === 0 ? 2 : 1;
|
|
})
|
|
.attr('stroke', function (d: number) {
|
|
return d === 0 ? 'var(--foreground-lightest)' : 'var(--foreground-lighter)';
|
|
});
|
|
}
|
|
|
|
// function reset() {
|
|
// svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
|
|
// }
|
|
|
|
view.on('mousemove', (ev: MouseEvent) => {
|
|
// not using offsetXY because `translate` transforms on .inner mess it up
|
|
const viewBBox = (view.node() as HTMLElement).getBoundingClientRect();
|
|
const [x, y] = d3
|
|
.zoomTransform(view.select('.content').node() as HTMLElement)
|
|
.invert([ev.clientX - viewBBox.left, ev.clientY - viewBBox.top]);
|
|
|
|
currentX = xScale.invert(x);
|
|
currentY = yScale.invert(y);
|
|
});
|
|
|
|
d3.select(viewEl)
|
|
.call(zoom)
|
|
.on('dblclick.zoom', (_ev: MouseEvent) => {
|
|
selectorCoords = [currentX, currentY];
|
|
});
|
|
|
|
autofit();
|
|
|
|
loaded = true;
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
tick().then(() => init());
|
|
});
|
|
resizeObserver.observe(viewEl);
|
|
});
|
|
|
|
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
|
|
const value = ev.detail;
|
|
if (value.t !== 'Address') return;
|
|
const address = value.c;
|
|
|
|
const [xValue, yValue] = selectorCoords;
|
|
selectorCoords = null;
|
|
await Promise.all(
|
|
[
|
|
[x, xValue],
|
|
[y, yValue]
|
|
].map(([axis, value]: [string, number]) =>
|
|
api.putEntityAttribute(address, axis, {
|
|
t: 'Number',
|
|
c: value
|
|
})
|
|
)
|
|
);
|
|
await loadPoints();
|
|
}
|
|
</script>
|
|
|
|
<div class="surface">
|
|
<div class="header ui">
|
|
<div class="axis-selector">
|
|
<div class="label">X</div>
|
|
<Selector
|
|
types={['Attribute', 'NewAttribute']}
|
|
initial={x ? { t: 'Attribute', name: x } : undefined}
|
|
on:input={(ev) => {
|
|
if (ev.detail.t === 'Attribute') x = ev.detail.name;
|
|
}}
|
|
/>
|
|
<div class="value">
|
|
{(Math.round(currentX * 100) / 100).toLocaleString('en', {
|
|
useGrouping: false,
|
|
minimumFractionDigits: 2
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div class="axis-selector">
|
|
<div class="label">Y</div>
|
|
<Selector
|
|
types={['Attribute', 'NewAttribute']}
|
|
initial={y ? { t: 'Attribute', name: y } : undefined}
|
|
on:input={(ev) => {
|
|
if (ev.detail.t === 'Attribute') y = ev.detail.name;
|
|
}}
|
|
/>
|
|
<div class="value">
|
|
{(Math.round(currentY * 100) / 100).toLocaleString('en', {
|
|
useGrouping: false,
|
|
minimumFractionDigits: 2
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="view" class:loaded bind:this={viewEl}>
|
|
<div class="ui view-mode-selector">
|
|
<div class="label">
|
|
{$i18n.t('View as')}
|
|
</div>
|
|
<select bind:value={viewMode}>
|
|
<option value="point">{$i18n.t('Point')}</option>
|
|
<option value="link">{$i18n.t('Link')}</option>
|
|
<option value="card">{$i18n.t('Card')}</option>
|
|
<!-- <option value="preview">{$i18n.t("Preview")}</option> -->
|
|
</select>
|
|
</div>
|
|
{#if !loaded}
|
|
<div class="loading">
|
|
<Spinner centered="absolute" />
|
|
</div>
|
|
{/if}
|
|
<div class="content">
|
|
{#if selectorCoords !== null}
|
|
<div
|
|
class="point selector"
|
|
style="
|
|
left: {selectorCoords[0]}px;
|
|
top: {viewHeight - selectorCoords[1]}px"
|
|
>
|
|
<Selector
|
|
types={['Address', 'NewAddress']}
|
|
on:input={onSelectorInput}
|
|
on:focus={(ev) => {
|
|
if (!ev.detail) selectorCoords = null;
|
|
}}
|
|
bind:this={selector}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#each points as point}
|
|
<div class="point" style="left: {point.x}px; top: {viewHeight - point.y}px">
|
|
<div class="inner">
|
|
{#if viewMode == 'link'}
|
|
<UpObject link address={point.address} />
|
|
{:else if viewMode == 'card'}
|
|
<UpObjectCard address={point.address} />
|
|
{:else if viewMode == 'preview'}
|
|
<BlobPreview address={point.address} />
|
|
{:else if viewMode == 'point'}
|
|
<SurfacePoint address={point.address} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<svg />
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.surface {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1em;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin: 0.5em 0;
|
|
|
|
.axis-selector {
|
|
display: flex;
|
|
gap: 1em;
|
|
align-items: center;
|
|
|
|
.label {
|
|
font-size: 1rem;
|
|
&::after {
|
|
content: ':';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.view {
|
|
flex-grow: 1;
|
|
position: relative;
|
|
|
|
overflow: hidden;
|
|
|
|
:global(svg) {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
:global(.tick text) {
|
|
color: var(--foreground-lightest);
|
|
font-size: 1rem;
|
|
text-shadow: 0 0 0.25em var(--background);
|
|
}
|
|
|
|
.content {
|
|
transform-origin: 0 0;
|
|
}
|
|
|
|
.point {
|
|
position: absolute;
|
|
transform-origin: 0 0;
|
|
|
|
.inner {
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
&:hover {
|
|
z-index: 99;
|
|
}
|
|
}
|
|
|
|
.view-mode-selector {
|
|
position: absolute;
|
|
top: 2rem;
|
|
right: 1.5em;
|
|
padding: 0.66em;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--foreground-lighter);
|
|
background: var(--background);
|
|
opacity: 0.66;
|
|
transition: opacity 0.25s;
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
&:not(.loaded) {
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
.view-mode-selector {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5em;
|
|
align-items: center;
|
|
}
|
|
|
|
.ui {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.loading {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
z-index: 99;
|
|
transform: scale(3);
|
|
}
|
|
</style>
|