Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions artefact_kino/artefact_kino.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 21 additions & 16 deletions artefact_kino/lib/artefact_kino.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -207,11 +210,15 @@ defmodule ArtefactKino do
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");

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 = `
<div style="padding:6px 8px;border-bottom:1px solid #333;">
<div style="font-size:13px;color:#aaa;">${escapeHtml(data.title)}</div>
${data.description
? `<div style="font-size:11px;color:#888;margin-top:2px;font-style:italic;white-space:pre-line;">${escapeHtml(data.description)}</div>`
? `<div style="${descStyle}">${escapeHtml(data.description)}</div>`
: ""}
</div>`;

Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions artefact_kino/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Loading