fix(lit-components): fix rendering and interaction issues in force-graph, html-table, and draggable-dom
Build and Deploy / build-and-deploy (push) Has been cancelled

- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes.
- lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode.
- lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
2026-05-17 00:41:14 -07:00
parent bbdbeb9788
commit b9e42c3ef4
253 changed files with 2555 additions and 4471 deletions
@@ -0,0 +1,10 @@
import { GraphData, GraphNode } from "./graph";
export interface RenderContext {
data: GraphData;
element: HTMLElement;
onHover: (node?: GraphNode) => void;
instance?: any;
}
export type Renderer = (context: RenderContext) => any;
@@ -0,0 +1,46 @@
export class Graph {
private ids = new Set();
private graph: GraphData = {
nodes: [],
links: [],
};
addNode<T = any>(node: GraphNode<T>) {
if (this.ids.has(node.id)) {
return this.graph.nodes.find((n) => n.id === node.id)!;
}
this.ids.add(node.id);
this.graph.nodes.push(node);
return node;
}
addLink<T = any>(link: GraphLink<T>) {
this.graph.links.push(link);
return link;
}
toJSON() {
return this.graph;
}
}
export interface GraphNode<T = any> {
id: string;
name?: string;
group?: string;
value?: T;
}
export interface GraphLink<T = any> {
source: string;
target: string;
name?: string;
value?: T;
}
export interface GraphData<A = any, B = any> {
name?: string;
description?: string;
nodes: GraphNode<A>[];
links: GraphLink<B>[];
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./lit-force-graph";
describe("Lit Force Graph Component", () => {
test("registers lit-force-graph custom element successfully", () => {
expect(customElements.get("lit-force-graph")).toBeDefined();
});
test("mounts lit-force-graph container successfully", async () => {
document.body.innerHTML = "<lit-force-graph></lit-force-graph>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("lit-force-graph");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,235 @@
import { html, css, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
export const tagName = "lit-force-graph";
@customElement(tagName)
export class LitForceGraph extends LitElement {
static styles = css`
:host {
display: block;
background-color: var(--graph-background-color, #000011);
color: var(--graph-foreground-color, #ffffff);
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
position: relative;
}
#graph {
width: 100%;
height: 100%;
}
#controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
}
#controls div {
padding: 5px;
}
#info {
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-start;
pointer-events: none;
}
#tooltips {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
justify-content: center;
pointer-events: none;
}
.node-tooltip {
background-color: var(--graph-foreground-color, #ffffff);
color: var(--graph-background-color, #000011);
border-radius: 5px;
font-size: 12px;
padding: 5px;
opacity: 0.8;
}
#graph-description {
opacity: 0.67;
}
`;
@query("#graph") graph!: HTMLElement;
@property() src = "";
@property() mode = "2D";
@property({ type: Object }) data?: GraphData;
@state() hovered?: GraphNode;
@state() private _instance?: any;
renderers = new Map<string, () => Promise<Renderer>>([
["2D", () => import("./modes/mode-2d").then((m) => m.render)],
["3D", () => import("./modes/mode-3d").then((m) => m.render)],
["AR", () => import("./modes/mode-ar").then((m) => m.render)],
["VR", () => import("./modes/mode-vr").then((m) => m.render)],
]);
render() {
return html` <main
@drop="${this.onDrop}"
@dragover="${(e: Event) => e.preventDefault()}"
>
<div id="graph"></div>
<div id="controls">
<div>
<label for="render-mode">Render mode</label>
<select id="render-mode" .value=${this.mode} @change=${this.onChangeMode}>
${Array.from(this.renderers.keys()).map((mode) => {
return html` <option value="${mode}" ?selected=${this.mode === mode}>${mode}</option> `;
})}
</select>
</div>
</div>
<div id="info">
<h2 id="graph-name">${this.data?.name}</h2>
<div id="graph-description">${this.data?.description}</div>
</div>
<div id="tooltips">
${this.hovered
? html` <div class="node-tooltip">
${this.hovered?.name ?? this.hovered?.id}
</div>`
: html``}
</div>
</main>`;
}
async firstUpdated() {
await this.refresh();
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
prefersDark.addEventListener("change", () => {
this._updateGraph();
});
}
protected updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("mode")) {
if (this._instance) {
this.graph.innerHTML = "";
this._instance = undefined;
}
}
if (changedProperties.has("data") || changedProperties.has("mode")) {
this._updateGraph();
}
if (changedProperties.has("src") && this.src) {
this.refresh();
}
}
/**
* Set the graph data and update the renderer
*
* @param data Graph JSON
*/
setData(data: GraphData) {
this.data = data;
}
private async _updateGraph() {
if (!this.data || !this.graph) return;
const rendererFactory = this.renderers.get(this.mode);
if (rendererFactory) {
try {
const renderer = await rendererFactory();
this._instance = renderer({
element: this.graph,
data: this.data,
onHover: (node) => (this.hovered = node),
instance: this._instance,
});
} catch (e) {
console.error("Failed to load or run renderer for mode", this.mode, e);
}
}
}
private async refresh() {
// Get json from script tag
const children = Array.from(this.children);
const elem = children.find((child) => child.tagName === "SCRIPT");
if (elem) {
if (elem.textContent) {
try {
const data = JSON.parse(elem.textContent);
if (data) this.setData(data);
} catch (e) {
console.error("Failed to parse graph data from script tag", e);
}
} else if (elem.hasAttribute("src")) {
const url = elem.getAttribute("src")!;
try {
const data = await fetch(url).then((res) => res.json());
if (data) this.setData(data);
} catch (e) {
console.error("Failed to fetch graph data from script src", url, e);
}
}
} else if (this.src) {
try {
const data = await fetch(this.src).then((res) => res.json());
if (data) this.setData(data);
} catch (e) {
console.error("Failed to fetch graph data from src property", this.src, e);
}
}
}
private onChangeMode(e: Event) {
this.mode = (e.target as HTMLSelectElement).value;
}
private onDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const json = JSON.parse(reader.result as string);
this.setData(json);
} catch (e) {
console.error("Failed to parse dropped graph data", e);
}
};
reader.readAsText(file);
}
return false;
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
@@ -0,0 +1,66 @@
import ForceGraph from "force-graph";
import { RenderContext } from "../classes/context";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim() || "#555";
const bgColor = style.getPropertyValue("--graph-background-color").trim() || "#000011";
const fgColor = style.getPropertyValue("--graph-foreground-color").trim() || "#ffffff";
const nodeColor = style.getPropertyValue("--graph-node-color").trim() || "#999";
const width = context.element.clientWidth || Number(style.width.slice(0, -2)) || window.innerWidth;
const height = context.element.clientHeight || Number(style.height.slice(0, -2)) || window.innerHeight;
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(width)
.height(height)
.cooldownTicks(100)
.backgroundColor(bgColor)
.linkColor(() => lineColor)
.linkWidth(0.2)
.nodeCanvasObject((node: any, ctx, globalScale) => {
// Draw a circle
ctx.beginPath();
const size = 5 / globalScale;
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor;
ctx.fill();
ctx.lineWidth = 1 / globalScale;
ctx.strokeStyle = lineColor;
ctx.stroke();
if (globalScale >= 4) {
const label = node.name ?? node.id;
const fontSize = 12 / globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(
(n) => n + fontSize * 0.2
); // some padding
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = fgColor;
// Measure text
ctx.fillText(label, node.x + size * 2 + textWidth / 2, node.y);
node.__bckgDimensions = bckgDimensions;
}
})
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
});
return graph;
}
@@ -0,0 +1,45 @@
import ForceGraph from "3d-force-graph";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph({
controlType: "trackball",
rendererConfig: { antialias: true, alpha: true },
});
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim() || "#555";
const bgColor = style.getPropertyValue("--graph-background-color").trim() || "#000011";
const nodeColor = style.getPropertyValue("--graph-node-color").trim() || "#999";
const width = context.element.clientWidth || Number(style.width.slice(0, -2)) || window.innerWidth;
const height = context.element.clientHeight || Number(style.height.slice(0, -2)) || window.innerHeight;
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(width)
.height(height)
.showNavInfo(false)
.linkColor(() => lineColor)
.backgroundColor(bgColor)
.nodeThreeObject((node: any) => {
const color = node.color ?? nodeColor;
node.color = color;
return false as any;
})
.nodeThreeObjectExtend(true)
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
})
.cooldownTicks(100);
return graph;
}
@@ -0,0 +1,21 @@
import "aframe";
import "@ar-js-org/ar.js";
import ForceGraph from "3d-force-graph-ar";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(Number(style.width.slice(0, -2)) || window.innerWidth)
.height(Number(style.height.slice(0, -2)) || window.innerHeight)
.cooldownTicks(100);
return graph;
}
@@ -0,0 +1,20 @@
import ForceGraph from "3d-force-graph-vr";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(Number(style.width.slice(0, -2)) || window.innerWidth)
.height(Number(style.height.slice(0, -2)) || window.innerHeight)
.showNavInfo(false)
.cooldownTicks(100);
return graph;
}