From 176bf25d7f2903254325b404a2dc629d27f8795d Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 13 May 2026 06:34:34 +0930 Subject: [PATCH] improved kino and documentation --- artefact_kino/artefact_kino.livemd | 58 ++++++++++++++++++++++++++++++ artefact_kino/lib/artefact_kino.ex | 37 ++++++++++--------- artefact_kino/mix.lock | 1 + 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 59f72cb..2298981 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -96,6 +96,64 @@ Harmonise merges labels and properties of bound nodes, and properties of bound r The knowledge we have harmonized here is ArtefactCreateMerge, a combination of Artefact and the CreateMerge Artefacts. +## Side by Side + +A common pattern is showing knowledge grow progressively — a section artefact on the left, +the integrated whole on the right, chapter by chapter. `Kino.Layout.grid` places two +`ArtefactKino` widgets side by side. Two small things make this work well. + +**Consistent colours.** The same label string always produces the same colour, regardless +of how many labels either artefact contains. An `Agent` node will be the same shade of +blue-green in a two-label artefact as it is in a ten-label one. You can follow a concept +by colour across panels without thinking about it. + +**Stable header height.** When an artefact carries a long description — a Cypher query or +a chapter summary — the header expands and the graph viewports in the two panels no longer +align. Pass `max_description_lines:` to cap the description to a fixed number of lines with +overflow hidden. Both panels keep the same height and the graphs line up. + +```elixir +require Artefact + +section = + Artefact.new!( + title: "Section 1", + base_label: "Knowing", + description: "MATCH (a:Agent)-[:KNOWS]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + knowing: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :knowing] + ] + ) + +integrated = + Artefact.new!( + title: "Integrated", + base_label: "Valuing", + description: "MATCH (a:Agent)-[:KNOWS|VALUES]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + know: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"], + value: [labels: ["Concept"], properties: %{"name" => "Valuing"}, uuid: "019da897-0000-7000-8000-000000000002"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :know], + [from: :matt, type: "VALUES", to: :value] + ] + ) + +Kino.Layout.grid([ + ArtefactKino.new(section, max_description_lines: 2), + ArtefactKino.new(integrated, max_description_lines: 2) +], columns: 2) +``` + +Notice that the `Agent` node (Matt) is the same colour in both panels, and the two graph +viewports sit at the same height despite the descriptions being different lengths. + ## What Next? Artefacts are knowledge graph fragments. We like to store knowledge in graphical databases, so explore exporting cypher to a graph database with creat and merge. Review the CreateMerge Artifact to understand what create and merge are best for. diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index c4bb2e6..17d7677 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -28,14 +28,18 @@ defmodule ArtefactKino do Options: - `default:` — `:create` (default) or `:merge` + - `max_description_lines:` — integer; caps the description to this many + lines with overflow hidden. Useful when placing widgets side by side so + that long descriptions do not cause misaligned graph viewports. """ def new(%Artefact{} = artefact, opts \\ []) do Artefact.validate!(artefact) default = Keyword.get(opts, :default, :create) - Kino.JS.new(__MODULE__, build_data(artefact, default)) + max_description_lines = Keyword.get(opts, :max_description_lines, nil) + Kino.JS.new(__MODULE__, build_data(artefact, default, max_description_lines)) end - defp build_data(artefact, default) do + defp build_data(artefact, default, max_description_lines) do %{ nodes: vis_nodes(artefact), edges: vis_edges(artefact), @@ -46,6 +50,7 @@ defmodule ArtefactKino do default: Atom.to_string(default), title: artefact.title || artefact.base_label || "Artefact", description: artefact.description, + max_description_lines: max_description_lines, artefact_rows: artefact_rows(artefact), nodes_rows: nodes_rows(artefact), rels_rows: rels_rows(artefact) @@ -135,13 +140,12 @@ defmodule ArtefactKino do // -- colour theory -- - function buildLabelHues(nodes) { - const labels = new Set(); - nodes.forEach(n => n.labels.forEach(l => labels.add(l))); - const sorted = [...labels].sort(); - const hues = {}; - sorted.forEach((l, i) => { hues[l] = (i / sorted.length) * 360; }); - return hues; + function labelToHue(label) { + let h = 0; + for (let i = 0; i < label.length; i++) { + h = (h * 31 + label.charCodeAt(i)) & 0xffffffff; + } + return (h >>> 0) % 360; } function blendHues(hues) { @@ -164,10 +168,9 @@ defmodule ArtefactKino do return "#" + [r, g, b].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, "0")).join(""); } - function nodeColour(labels, labelHues) { + function nodeColour(labels) { if (!labels || labels.length === 0) return { bg: "#2a2a2a", border: "#555" }; - const hues = labels.map(l => labelHues[l] ?? 0); - const blended = blendHues(hues); + const blended = blendHues(labels.map(labelToHue)); const [r1, g1, b1] = hslToRGB(blended, 55, 30); const [r2, g2, b2] = hslToRGB(blended, 65, 50); return { @@ -207,11 +210,15 @@ defmodule ArtefactKino do .replace(/"/g, """) .replace(/'/g, "'"); + const descStyle = data.max_description_lines + ? `font-size:11px;color:#888;margin-top:2px;font-style:italic;display:-webkit-box;-webkit-line-clamp:${data.max_description_lines};-webkit-box-orient:vertical;overflow:hidden;` + : `font-size:11px;color:#888;margin-top:2px;font-style:italic;white-space:pre-line;`; + const headerHtml = `
${escapeHtml(data.title)}
${data.description - ? `
${escapeHtml(data.description)}
` + ? `
${escapeHtml(data.description)}
` : ""}
`; @@ -373,10 +380,8 @@ defmodule ArtefactKino do }) .then(() => { if (!window.vis) return; - const labelHues = buildLabelHues(data.nodes); - const nodes = new vis.DataSet(data.nodes.map(n => { - const { bg, border } = nodeColour(n.labels, labelHues); + const { bg, border } = nodeColour(n.labels); return { ...n, shape: "ellipse", diff --git a/artefact_kino/mix.lock b/artefact_kino/mix.lock index 34d8e38..f8392ae 100644 --- a/artefact_kino/mix.lock +++ b/artefact_kino/mix.lock @@ -7,5 +7,6 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, }