upend/webui/src/components/SurfaceColumn.svelte

458 lines
11 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>