# HG changeset patch # User drewp@bigasterisk.com # Date 1647234030 25200 # Node ID 2468f2227d22eefffd8d6e2d23255cbc40a0ab69 # Parent 4bb8c7775c838bda8caca9daca8b8a327b260915 make src/layout/ and src/render/ separation diff -r 4bb8c7775c83 -r 2468f2227d22 src/Layout.test.ts --- a/src/Layout.test.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,163 +0,0 @@ -import { Quad, Store, Term } from "n3"; -import { n3Graph } from "./fetchAndParse"; -import { AlignedTable, Layout, LayoutResult } from "./Layout"; -import { EX, rdf } from "./namespaces"; -import { ViewConfig } from "./ViewConfig"; - -const twoStatements = async (): Promise => { - return n3Graph(` - @prefix : . - :g1 { - :a0 :b0 :c0 . - :d0 :e0 :f0 . - } - `); -}; - -const typedStatements = async (): Promise => { - return n3Graph(` - @prefix : . - :g1 { - :a a :T1 ; :color :red . - :b a :T1 ; :color :blue . - :c a :T1 . - :d a :T2 ; :size :big . - :e a :T1,:T2; :size :small - } - `); -}; -function G1(s: Term, p: Term, o: Term): Quad { - return new Quad(s, p, o, EX("g1")); -} - -describe("Layout", () => { - it("accepts a ViewConfig", async () => { - const vc = new ViewConfig(); - await vc.readFromGraph(` - @prefix ex: . - @prefix rdfs: . - - <> a ex:View; rdfs:label "repos" .`); - const layout = new Layout(vc); - const lr = layout.plan(await twoStatements()); - }); - it("defaults to putting all triples in the ungrouped list", async () => { - const layout = new Layout(); - const lr = layout.plan(await twoStatements()); - expect(lr).toEqual({ - sections: [ - { - statements: [ - G1(EX("a0"), EX("b0"), EX("c0")), - G1(EX("d0"), EX("e0"), EX("f0")), - ], - }, - ], - }); - }); - describe("makes a table as requested by ViewConfig", () => { - let lr: LayoutResult; - - beforeAll(async () => { - const vc = new ViewConfig(); - await vc.readFromGraph(` - @prefix : . - @prefix rdfs: . - - <> a :View; :table [ :primaryType :T1 ] .`); - const layout = new Layout(vc); - lr = layout.plan(await typedStatements()); - }); - it("returns 2 sections", ()=>{ - expect(lr.sections).toHaveLength(2); - }) - it("puts the right type in the table", async () => { - const sec0 = lr.sections[0] as AlignedTable; - expect(sec0.columnHeaders).toEqual([ - { rdfType: EX("T1"), pred: EX("color") }, - { rdfType: EX("T1"), pred: EX("size") } - ]) - expect(sec0.rows).toEqual([ - [EX("a"), EX("red"), null], - [EX("b"), EX("blue"),null], - [EX("c"), null, null], - [EX("e"), null, EX('small')], - ]); - }); - it("leaves the rest ungrouped", async () => { - expect(lr.sections[1]).toEqual({ - statements: [ - G1(EX("d"), rdf.type, EX("T2")), - G1(EX("d"), EX("size"), EX("big")), - ], - }); - }); - }); - it("makes a table out of ungrouped triples with the same type", async () => {}); -}); - -// describe("equality", () => { -// test("investigation of https://github.com/rdfjs/N3.js/issues/265", () => { -// const x = namedNode("x"); -// const x2 = namedNode("x"); -// // (NamedNode.prototype as any).hashCode = () => 0; -// // expect((x as any).hashCode()).toEqual((x2 as any).hashCode()) -// expect(x === x2).toBeFalsy(); -// expect(x == x2).toBeFalsy(); -// expect(x.equals(x2)).toBeTruthy(); -// let imap = Immutable.Map(); -// imap = imap.set(x, 11); -// imap = imap.set(x, 22); -// imap = imap.set(x2, 33); -// expect(imap.has(x)).toBeTruthy(); -// expect(imap.has(x2)).toBeTruthy(); -// expect(imap.size).toEqual(1); -// }); -// }); - -// describe("groupByRdfType", () => { -// test("finds multiple graphs", () => {}); -// test("works", async () => { -// const store = new Store(); - -// const parser = new Parser(); -// await new Promise((res, rej) => { -// parser.parse( -// `PREFIX : -// :rs1 a :Foo; :pred1 "obj1" . -// :rs2 a :Foo; :pred1 "obj2" . -// :rs3 a :Bar . -// :rs4 :pred1 "obj4" . -// `, -// (error, quad: Quad, prefixes: Prefixes) => { -// if (quad) { -// store.addQuad(quad); -// } else { -// res(undefined); -// } -// } -// ); -// }); -// const grouped = groupByRdfType(store); -// expect(Array.from(grouped.byType.keys())).toHaveLength(2); -// expect(grouped.byType.get(namedNode("urn:Foo"))).toEqual( -// Immutable.Set([namedNode("urn:rs1"), namedNode("urn:rs2")]) -// ); -// expect(grouped.byType.get(namedNode("urn:Bar"))).toEqual( -// Immutable.Set([namedNode("urn:rs3")]) -// ); -// expect(grouped.untypedSubjs).toEqual([namedNode("urn:rs4")]); -// }); - -// describe("MultiSubjsTypeBlockLayout", () => { -// test("gathers subjs", () => { - -// }); -// test("gathers preds", () => { - -// }); -// test("cells reports filled cells", () => { - -// }); -// }); -// }); diff -r 4bb8c7775c83 -r 2468f2227d22 src/Layout.ts --- a/src/Layout.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,200 +0,0 @@ -// Organize graph data into tables (column orders, etc) for the view layer. - -import Immutable from "immutable"; // mostly using this for the builtin equals() testing, since NamedNode(x)!=NamedNode(x) -import { NamedNode, Quad, Store, Term } from "n3"; -import { rdf } from "./namespaces"; -import { TableDesc, ViewConfig } from "./ViewConfig"; - -type UriSet = Immutable.Set; -export type TypeToSubjs = Immutable.Map; - -// https://github.com/rdfjs/N3.js/issues/265 -(NamedNode.prototype as any).hashCode = () => 0; - -interface ColumnHeader { - rdfType: NamedNode; - pred: NamedNode; -} -export interface AlignedTable { - columnHeaders: ColumnHeader[]; - rows: (Term | null)[][]; // each row is 1 wider than columnHeaders since the 1st element is the subject for that row -} -interface FreeStatements { - statements: Quad[]; -} -export interface LayoutResult { - sections: (AlignedTable | FreeStatements)[]; -} - -class AlignedTableBuilder { - columnPreds = Immutable.List(); - subjRowIndices = Immutable.Map(); - rows: (Term | null)[][] = []; - constructor( - public rdfType: NamedNode /* plus join types, sort instructions */ - ) {} - - addQuad(q: Quad) { - const pred = q.predicate as NamedNode; - const omittedColumn = pred.equals(rdf.type); - if (!this.columnPreds.contains(pred) && !omittedColumn) { - this.columnPreds = this.columnPreds.push(pred); // this is putting cols in random order - this.rows.forEach((r) => r.push(null)); - } - - const predIndex = omittedColumn ? null : this.columnPreds.indexOf(pred); - let rowIndex = this.subjRowIndices.get(q.subject as NamedNode); - if (rowIndex === undefined) { - const newRow = new Array(1 + this.columnPreds.size).fill(null); - newRow[0] = q.subject; - this.rows.push(newRow); - rowIndex = this.rows.length - 1; - this.subjRowIndices = this.subjRowIndices.set( - q.subject as NamedNode, - rowIndex - ); - } - if (predIndex !== null) { - this.rows[rowIndex][1 + predIndex] = q.object; - } - } - - value(): AlignedTable { - this.rows.sort((a, b) => { - const uriA = (a[0] as NamedNode).value, - uriB = (b[0] as NamedNode).value; - return uriA.localeCompare(uriB); - }); - const headers = this.columnPreds - .map((pred) => { - return { rdfType: this.rdfType, pred: pred }; - }) - .toArray(); - return { columnHeaders: headers, rows: this.rows }; - } -} - -function findTypesNeededForTables(viewConfig?: ViewConfig): UriSet { - const typesToGather: NamedNode[] = []; - if (viewConfig) { - viewConfig.tables.forEach((t: TableDesc) => { - typesToGather.push(t.primary); - }); - } - return Immutable.Set(typesToGather); -} - -function findSubjectsWithTypes(graph: Store, typesToGatherSet: UriSet): UriSet { - const subjectsToGather: NamedNode[] = []; - graph.forEach( - (q: Quad) => { - if (typesToGatherSet.contains(q.object as NamedNode)) { - subjectsToGather.push(q.subject as NamedNode); - } - }, - null, - rdf.type, - null, - null - ); - return Immutable.Set(subjectsToGather); -} - -export class Layout { - constructor(public viewConfig?: ViewConfig) {} - plan(graph: Store): LayoutResult { - const typesToGatherSet = findTypesNeededForTables(this.viewConfig); - - const subjectsToGatherSet = findSubjectsWithTypes(graph, typesToGatherSet); - const ungrouped: Quad[] = []; - const vc = this.viewConfig; - const table = - vc && vc.tables.length > 0 - ? new AlignedTableBuilder(vc.tables[0].primary) - : null; - - graph.forEach( - (q: Quad) => { - if (!subjectsToGatherSet.contains(q.subject as NamedNode) || !table) { - ungrouped.push(q); - } else { - table.addQuad(q); - } - }, - null, - null, - null, - null - ); - const res: LayoutResult = { sections: [] }; - if (table) { - res.sections.push(table.value()); - } - res.sections.push({ statements: ungrouped }); - return res; - } -} - -// interface ISP { -// subj: NamedNode; -// pred: NamedNode; -// } -// const SP = Immutable.Record({ -// subj: new NamedNode(""), -// pred: new NamedNode(""), -// }); - -// // One table of rows with a common rdf:type. -// export class MultiSubjsTypeBlockLayout { -// subjs: NamedNode[]; -// preds: NamedNode[]; -// graphCells: Immutable.Map>; -// constructor(graph: Store, byType: TypeToSubjs, table: TableDesc) { -// const subjSet = byType.get(table.primary); -// this.subjs = subjSet ? Array.from(subjSet) : []; -// this.subjs.sort(); - -// let preds = Immutable.Set(); - -// this.graphCells = Immutable.Map>().withMutations( -// (mutGraphCells) => { -// this.subjs.forEach((subj: NamedNode) => { -// graph.forEach( -// (q: Quad) => { -// if (!Util.isNamedNode(q.predicate)) { -// throw new Error(); -// } - -// const pred = q.predicate as NamedNode; -// if (pred.equals(rdf.type)) { -// // the whole block is labeled with the type -// return; -// } -// preds = preds.add(pred); -// const cellKey = this.makeCellKey(subj, pred); -// mutGraphCells.set( -// cellKey, -// mutGraphCells.get(cellKey, Immutable.Set()).add(q.object) -// ); -// }, -// subj, -// null, -// null, -// null -// ); -// }); -// } -// ); -// this.preds = Array.from(preds); -// this.preds.splice(this.preds.indexOf(rdf.type), 1); -// // also pull out label, which should be used on 1st column -// this.preds.sort(); -// } - -// makeCellKey(subj: NamedNode, pred: NamedNode): ISP { -// return SP({ -// subj: subj, -// pred: pred, -// }); -// } -// } diff -r 4bb8c7775c83 -r 2468f2227d22 src/NodeDisplay.ts --- a/src/NodeDisplay.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ -import { html, TemplateResult } from "lit"; -import { Literal, NamedNode, Term, Util } from "n3"; -import { SuffixLabels } from "./suffixLabels"; - -export class NodeDisplay { - labels: SuffixLabels; - constructor(labels: SuffixLabels) { - this.labels = labels; - } - render(n: Term | NamedNode): TemplateResult { - if (Util.isLiteral(n)) { - n = n as Literal; - let dtPart: any = ""; - if (n.datatype && - n.datatype.value != "http://www.w3.org/2001/XMLSchema#string" && // boring - n.datatype.value != - "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" // boring - ) { - dtPart = html` - ^^ ${this.render(n.datatype)} - `; - } - return html` ${n.value}${dtPart} `; - } - - if (Util.isNamedNode(n)) { - n = n as NamedNode; - let shortened = false; - let uriValue: string = n.value; - for (let [long, short] of [ - ["http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:"], - ["http://www.w3.org/2000/01/rdf-schema#", "rdfs:"], - ["http://purl.org/dc/elements/1.1/", "dc:"], - ["http://www.w3.org/2001/XMLSchema#", "xsd:"], - ]) { - if (uriValue.startsWith(long)) { - uriValue = short + uriValue.substr(long.length); - shortened = true; - break; - } - } - if (!shortened) { - let dn: string | undefined = this.labels.getLabelForNode(uriValue); - if (dn === undefined) { - throw new Error(`dn=${dn}`); - } - uriValue = dn; - } - - return html` ${uriValue} `; - } - - return html` [${n.termType} ${n.value}] `; - } -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/README Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,2 @@ +layout/ -> everything that doesn't involve html +render/ -> everything that does involve html \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/ViewConfig.test.ts --- a/src/ViewConfig.test.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -import { Util } from "n3"; -import { ViewConfig } from "./ViewConfig"; - -describe("ViewModel", () => { - it("gets a table description", async () => { - const vc = new ViewConfig(); - - await vc.readFromGraph(` - @prefix ex: . - @prefix demo: . - @prefix rdfs: . - @prefix : . - - <> a ex:View ; rdfs:label "repos" . - <> ex:table demo:table1 . - demo:table1 - ex:primaryType :FilteredNic; - ex:joinType :Traffic . - `); - const NET = Util.prefix("http://bigasterisk.com/netRoutes/ns#"); - - expect(vc.tables[0].primary).toEqual(NET("FilteredNic")); - expect(vc.tables[0].joins).toEqual([NET("Traffic")]); - }); -}); diff -r 4bb8c7775c83 -r 2468f2227d22 src/ViewConfig.ts --- a/src/ViewConfig.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -// Load requested view (rdf data) and provide access to it -import { DataFactory, NamedNode, Store } from "n3"; -import { fetchAndParse, n3Graph } from "./fetchAndParse"; -import { EX, RDF } from "./namespaces"; -import { labelOrTail, uriValue } from "./rdf_value"; -const Uri = DataFactory.namedNode; - -function firstElem(seq: Iterable): E { - for (let e of seq) { - return e; - } - throw new Error("no elems"); -} - -export interface TableDesc { - uri: NamedNode; - primary: NamedNode; - joins: NamedNode[]; -} - -export class ViewConfig { - graph: Store; - viewRoot!: NamedNode; - url?: string; - tables: TableDesc[] = []; - - constructor() { - this.graph = new Store(); - } - - async readFromUrl(url: string | "") { - if (!url) { - return; - } - await fetchAndParse(url, this.graph); - - this._read(); - } - - async readFromGraph(n3: string) { - this.graph = await n3Graph(n3); - this._read(); - } - - _read() { - this.viewRoot = firstElem( - this.graph.getSubjects(RDF("type"), EX("View"), null) - ) as NamedNode; - for (let table of this.graph.getObjects(this.viewRoot, EX("table"), null)) { - const tableType = uriValue(this.graph, table, EX("primaryType")); - const joins: NamedNode[] = []; - for (let joinType of this.graph.getObjects(table, EX("joinType"), null)) { - joins.push(joinType as NamedNode); - } - joins.sort(); - this.tables.push({ - uri: table as NamedNode, - primary: tableType, - joins: joins, - }); - } - this.tables.sort(); - } - - label(): string { - return this.url !== undefined - ? labelOrTail(this.graph, Uri(this.url)) - : "unnamed"; - } -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/fetchAndParse.ts --- a/src/fetchAndParse.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,30 +0,0 @@ -import { Store, Parser, Quad, Prefixes } from "n3"; - -export async function fetchAndParse( - url: string, - store?: Store -): Promise { - const res = await fetch(url); - const body = await res.text(); - return n3Graph(body, store); -} - -export async function n3Graph(n3: string, store?: Store): Promise { - if (store === undefined) { - store = new Store(); - } - - const parser = new Parser({ format: "TriG" }); - await new Promise((res, rej) => { - parser.parse(n3, (error, quad: Quad, prefixes: Prefixes) => { - if (error) rej(error); - if (quad) { - store!.addQuad(quad); - } else { - res(undefined); - } - }); - }); - - return store; -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/graph_queries.ts --- a/src/graph_queries.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,101 +0,0 @@ -// import { DataFactory, Literal, N3Store, NamedNode, Util } from "n3"; -// const { literal, namedNode } = DataFactory; - -// // i think this one is a worse subset of graphLiteral, below -// export function getStringValue( -// store: N3Store | undefined, -// subj: NamedNode, -// pred: NamedNode, -// defaultValue: string = "" -// ): string { -// if (store === undefined) { -// // this is so you can use the function before you have a graph -// return "..."; -// } -// const objs = store.getObjects(subj, pred, null); -// if (objs.length == 0) { -// return defaultValue; -// } -// return objs[0].value; -// } - -// // workaround for uris that don't have good labels in the graph -// export function labelFromUri( -// uri: NamedNode, -// prefix: string, -// tailsToLabels: { [key: string]: string }, -// defaultLabel: string -// ) { -// let label = defaultLabel === undefined ? uri.value : defaultLabel; -// Object.entries(tailsToLabels).forEach(([tail, useLabel]) => { -// if (uri.equals(namedNode(prefix + tail))) { -// label = useLabel as string; -// } -// }); -// return label; -// } - -// export function graphLiteral( -// store: N3Store, -// subj: NamedNode, -// pred: string, -// notFoundResult?: string -// ): Literal { -// const keep: Array = []; -// store.forEach( -// q => { -// if (!Util.isLiteral(q.object)) { -// throw new Error("non literal found"); -// } -// let seen = false; -// for (let other of keep) { -// // why are we getting multiple matches for the same literal? seems like a bug -// if (other.equals(q.object)) { -// seen = true; -// } -// } -// if (!seen) { -// keep.push(q.object as Literal); -// } -// }, -// subj, -// namedNode(pred), -// null, -// null -// ); -// if (keep.length == 0) { -// return literal(notFoundResult || "(missing)"); -// } -// if (keep.length == 1) { -// return keep[0]; -// } -// console.log(`${subj.value} ${pred} had ${keep.length} objects:`, keep); -// return keep[0]; -// } - -// export function graphUriValue( -// store: N3Store, -// subj: NamedNode, -// pred: string -// ): NamedNode | undefined { -// const keep: Array = []; -// store.forEach( -// q => { -// if (!Util.isNamedNode(q.object)) { -// throw new Error("non uri found"); -// } -// keep.push(q.object as NamedNode); -// }, -// subj, -// namedNode(pred), -// null, -// null -// ); -// if (keep.length == 0) { -// return undefined; -// } -// if (keep.length == 1) { -// return keep[0]; -// } -// throw new Error("found multiple matches for pred"); -// } diff -r 4bb8c7775c83 -r 2468f2227d22 src/graph_view.ts --- a/src/graph_view.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,201 +0,0 @@ -import { html, TemplateResult } from "lit"; -import { DataFactory, Literal, NamedNode, Quad, Store, Term } from "n3"; -import { NodeDisplay } from "./NodeDisplay"; -import { SuffixLabels } from "./suffixLabels"; -import { - groupByRdfType, - MultiSubjsTypeBlockLayout, - predsForSubj, - TypeToSubjs, -} from "./tabulate"; -import { TableDesc, View } from "./view_loader"; - -const { namedNode } = DataFactory; - -// https://github.com/rdfjs/N3.js/issues/265 -if ((Literal.prototype as any).hashCode === undefined) { - (Literal.prototype as any).hashCode = () => 0; -} -if ((NamedNode.prototype as any).hashCode === undefined) { - (NamedNode.prototype as any).hashCode = () => 0; -} -export class GraphView { - url: string; - view: View; - graph: Store; - nodeDisplay: NodeDisplay; - constructor(url: string, viewUrl: string, graph: Store) { - this.url = url; - this.view = new View(viewUrl); - this.graph = graph; - - const labels = new SuffixLabels(); - this._addLabelsForAllTerms(this.graph, labels); - - if (this.view.graph) { - this._addLabelsForAllTerms(this.view.graph, labels); - } - this.nodeDisplay = new NodeDisplay(labels); - } - - _addLabelsForAllTerms(graph: Store, labels: SuffixLabels) { - console.log("_addLabelsForAllTerms"); - - graph.forEach( - (q: Quad) => { - if (q.subject.termType === "NamedNode") { - labels.planDisplayForNode(q.subject); - } - if (q.predicate.termType === "NamedNode") { - labels.planDisplayForNode(q.predicate); - } - if (q.object.termType === "NamedNode") { - labels.planDisplayForNode(q.object); - } - if (q.object.termType === "Literal" && q.object.datatype) { - labels.planDisplayForNode(q.object.datatype); - } - }, - null, - null, - null, - null - ); - } - - _subjPredObjsBlock(subj: NamedNode) { - const columns = predsForSubj(this.graph, subj); - return html` -
- ${this.nodeDisplay.render(subj)} - -
- ${columns.map((p) => { - return this._predObjsBlock(subj, p); - })} -
-
- `; - } - - _objCell(obj: Term) { - return html` -
- ${this.nodeDisplay.render(obj)} - -
- `; - } - - _predObjsBlock(subj: NamedNode, pred: NamedNode) { - const objsSet = new Set(); - this.graph.forEach( - (q: Quad) => { - objsSet.add(q.object); - }, - subj, - pred, - null, - null - ); - const objs = Array.from(objsSet.values()); - objs.sort(); - return html` -
- ${this.nodeDisplay.render(pred)} -
${objs.map(this._objCell.bind(this))}
-
- `; - } - - _drawObj(obj: Term): TemplateResult { - return html`
${this.nodeDisplay.render(obj)}
`; - } - - _drawColumnHead(pred: NamedNode): TemplateResult { - return html` ${this.nodeDisplay.render(pred)} `; - } - - _thead(layout: MultiSubjsTypeBlockLayout): TemplateResult { - return html` - - - - ${layout.preds.map(this._drawColumnHead.bind(this))} - - - `; - } - - _msbCell(layout: MultiSubjsTypeBlockLayout, subj: NamedNode) { - return (pred: NamedNode): TemplateResult => { - const objs = layout.graphCells.get(layout.makeCellKey(subj, pred)); - if (!objs || !objs.size) { - return html` `; - } - const objsList = Array.from(objs); - objsList.sort(); - return html` ${objsList.map(this._drawObj.bind(this))} `; - }; - } - - _instanceRow(layout: MultiSubjsTypeBlockLayout) { - return (subj: NamedNode): TemplateResult => { - return html` - - ${this.nodeDisplay.render(subj)} - ${layout.preds.map(this._msbCell(layout, subj))} - - `; - }; - } - - _multiSubjsTypeBlock(byType: TypeToSubjs, table: TableDesc) { - const layout = new MultiSubjsTypeBlockLayout(this.graph, byType, table); - - let typeNames = [html`${this.nodeDisplay.render(table.primary)}`]; - if (table.joins) { - typeNames.push(html` joined with [`); - for (let j of table.joins) { - typeNames.push(html`${this.nodeDisplay.render(j)}`); - } - typeNames.push(html`]`); - } - - return html` -
[icon] Resources of type ${typeNames}
-
- - ${this._thead(layout)} ${layout.subjs.map(this._instanceRow(layout))} -
-
- `; - } - - async makeTemplate(): Promise { - await this.view.ready; - const { byType, typesPresent, untypedSubjs } = groupByRdfType(this.graph); - let viewTitle = html` (no view)`; - if (this.view.url) { - viewTitle = html` using view - ${this.view.label()}`; - } - const tables = this.view.toplevelTables(typesPresent); - return html` -
-

- Current graph (${this.url})${viewTitle} -

-
- -
- ${tables.map((t: TableDesc) => this._multiSubjsTypeBlock(byType, t))} -
- ${untypedSubjs.map(this._subjPredObjsBlock.bind(this))} -
-
- `; - } -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/index.ts --- a/src/index.ts Sun Mar 13 21:57:52 2022 -0700 +++ b/src/index.ts Sun Mar 13 22:00:30 2022 -0700 @@ -1,150 +1,3 @@ -import { LitElement, html, render, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -import { Store } from "n3"; - -import { GraphView } from "./graph_view"; -import { StreamedGraphClient } from "./streamed_graph_client"; -import { style, addFontToRootPage } from "./style"; - -// export * from "./graph_queries"; - -export interface VersionedGraph { - version: number; - store: Store; -} - -@customElement("streamed-graph") -export class StreamedGraph extends LitElement { - @property({ type: String }) - url: string = ""; - @property({ type: String }) - view: string = ""; - @property({ type: Object }) - graph!: VersionedGraph; - - @property({ type: Boolean }) - expanded: boolean = false; - - @property({ type: String }) - status: string = ""; - - sg!: StreamedGraphClient; - graphViewDirty = true; - - static styles = style; - - render() { - const expandAction = this.expanded ? "-" : "+"; - return html` -
- - StreamedGraph [source]: ${this.status} -
-
- `; - } - - connectedCallback() { - super.connectedCallback(); - addFontToRootPage(); - const emptyStore = new Store(); - this.graph = { version: -1, store: emptyStore }; - - this._onUrl(this.url); // todo: watch for changes and rebuild - if (this.expanded) { - this.redrawGraph(); - } - } - - toggleExpand() { - this.expanded = !this.expanded; - if (this.expanded) { - this.redrawGraph(); - } else { - this.graphViewDirty = false; - this._graphAreaClose(); - } - } +import { StreamedGraph } from "./render/element"; - redrawGraph() { - this.graphViewDirty = true; - const rl: ()=>Promise = this._redrawLater.bind(this) - requestAnimationFrame(rl); - } - - _onUrl(url: string) { - if (this.sg) { - this.sg.close(); - } - this.sg = new StreamedGraphClient( - url, - this.onGraphChanged.bind(this), - (st) => { - this.status = st; - }, - [], //window.NS, - [] - ); - } - - onGraphChanged() { - this.graph = { - version: this.graph.version + 1, - store: this.sg.store, - }; - if (this.expanded) { - this.redrawGraph(); - } - this.dispatchEvent( - new CustomEvent("graph-changed", { detail: { graph: this.graph } }) - ); - } - - async _redrawLater() { - if (!this.graphViewDirty) return; - - if ((this.graph as VersionedGraph).store && this.graph.store) { - await this._graphAreaShowGraph( - new GraphView(this.url, this.view, this.graph.store) - ); - this.graphViewDirty = false; - } else { - this._graphAreaShowPending(); - } - } - - _graphAreaClose() { - this._setGraphArea(html``); - } - - _graphAreaShowPending() { - this._setGraphArea(html` waiting for data... `); - } - - async _graphAreaShowGraph(graphView: GraphView) { - this._setGraphArea(await graphView.makeTemplate()); - } - - _setGraphArea(tmpl: TemplateResult) { - const el = this.shadowRoot?.getElementById("graphView"); - if (!el) { - return; - } - render(tmpl, el); - } -} - -declare global { - interface HTMLElementTagNameMap { - "streamed-graph": StreamedGraph; - } -} - -// // allow child nodes to combine a few graphs and statics -// // -// // -// // -// // +export {StreamedGraph} diff -r 4bb8c7775c83 -r 2468f2227d22 src/json_ld_quads.test.ts --- a/src/json_ld_quads.test.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -import { eachJsonLdQuad } from "./json_ld_quads"; -import { Literal, DataFactory } from "n3"; -const { literal } = DataFactory; - -describe("eachJsonLdQuad", () => { - test("finds multiple graphs", () => {}); - test("returns quads", async () => { - let results: Array = []; - await eachJsonLdQuad( - [ - { - "@id": "http://example.com/g1", - "@graph": [ - { - "@id": "http://example.com/s1", - "http://example.com/p1": [{ "@value": "lit1" }] - } - ] - } - ], - (res: any) => results.push(res) - ); - expect(results).toHaveLength(1); - expect(results[0].subject.value).toEqual("http://example.com/s1"); - expect(results[0].predicate.value).toEqual("http://example.com/p1"); - expect((results[0].object as Literal).equals(literal("lit1"))).toBeTruthy(); - expect(results[0].graph.value).toEqual("http://example.com/g1"); - }); -}); diff -r 4bb8c7775c83 -r 2468f2227d22 src/json_ld_quads.ts --- a/src/json_ld_quads.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,67 +0,0 @@ -import * as jsonld from "jsonld"; -import { JsonLd, JsonLdArray } from "jsonld/jsonld-spec"; -import { Quad, NamedNode, DataFactory } from "n3"; -const { literal, quad, namedNode } = DataFactory; - -// const { rdf } = ns; -const rdf = { type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" }; - -function parseObjNode(obj: any) { - if (obj["@id"]) { - return namedNode(obj["@id"]); - } else { - if (obj["@value"] === undefined) { - throw new Error("no @id or @value"); - } - return literal(obj["@value"], obj["@language"] || obj["@type"]); - } -} - -function parsePred( - onQuad: (q: Quad) => void, - graphNode: NamedNode, - subjNode: NamedNode, - predKey: string, - subjGroup: any -) { - let predNode: NamedNode; - if (predKey === "@type") { - subjGroup["@type"].forEach((aType: string) => { - onQuad(quad(subjNode, namedNode(rdf.type), namedNode(aType), graphNode)); - }); - return; - } - predNode = namedNode(predKey); - subjGroup[predKey].forEach(function(obj: any) { - const objNode = parseObjNode(obj); - onQuad(quad(subjNode, predNode, objNode, graphNode)); - }); -} -function parseSubj( - onQuad: (q: Quad) => void, - graphNode: NamedNode, - subjGroup: { [predOrId: string]: any } -) { - const subjNode = namedNode(subjGroup["@id"]); - for (let predKey in subjGroup) { - if (predKey === "@id") { - continue; - } - parsePred(onQuad, graphNode, subjNode, predKey, subjGroup); - } -} -function parseGraph(onQuad: (q: Quad) => void, g: JsonLd) { - var graph = (g as { "@id": string })["@id"]; - var graphNode = namedNode(graph); - (g as { "@graph": JsonLdArray })["@graph"].forEach(subj => { - parseSubj(onQuad, graphNode, subj); - }); -} - -export async function eachJsonLdQuad( - jsonLdObj: object, - onQuad: (q: Quad) => void -) { - const expanded = await jsonld.expand(jsonLdObj); - (expanded as JsonLdArray).forEach((g: JsonLd) => parseGraph(onQuad, g)); -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/Layout.test.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/Layout.test.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,163 @@ +import { Quad, Store, Term } from "n3"; +import { n3Graph } from "./fetchAndParse"; +import { AlignedTable, Layout, LayoutResult } from "./Layout"; +import { EX, rdf } from "./namespaces"; +import { ViewConfig } from "./ViewConfig"; + +const twoStatements = async (): Promise => { + return n3Graph(` + @prefix : . + :g1 { + :a0 :b0 :c0 . + :d0 :e0 :f0 . + } + `); +}; + +const typedStatements = async (): Promise => { + return n3Graph(` + @prefix : . + :g1 { + :a a :T1 ; :color :red . + :b a :T1 ; :color :blue . + :c a :T1 . + :d a :T2 ; :size :big . + :e a :T1,:T2; :size :small + } + `); +}; +function G1(s: Term, p: Term, o: Term): Quad { + return new Quad(s, p, o, EX("g1")); +} + +describe("Layout", () => { + it("accepts a ViewConfig", async () => { + const vc = new ViewConfig(); + await vc.readFromGraph(` + @prefix ex: . + @prefix rdfs: . + + <> a ex:View; rdfs:label "repos" .`); + const layout = new Layout(vc); + const lr = layout.plan(await twoStatements()); + }); + it("defaults to putting all triples in the ungrouped list", async () => { + const layout = new Layout(); + const lr = layout.plan(await twoStatements()); + expect(lr).toEqual({ + sections: [ + { + statements: [ + G1(EX("a0"), EX("b0"), EX("c0")), + G1(EX("d0"), EX("e0"), EX("f0")), + ], + }, + ], + }); + }); + describe("makes a table as requested by ViewConfig", () => { + let lr: LayoutResult; + + beforeAll(async () => { + const vc = new ViewConfig(); + await vc.readFromGraph(` + @prefix : . + @prefix rdfs: . + + <> a :View; :table [ :primaryType :T1 ] .`); + const layout = new Layout(vc); + lr = layout.plan(await typedStatements()); + }); + it("returns 2 sections", ()=>{ + expect(lr.sections).toHaveLength(2); + }) + it("puts the right type in the table", async () => { + const sec0 = lr.sections[0] as AlignedTable; + expect(sec0.columnHeaders).toEqual([ + { rdfType: EX("T1"), pred: EX("color") }, + { rdfType: EX("T1"), pred: EX("size") } + ]) + expect(sec0.rows).toEqual([ + [EX("a"), EX("red"), null], + [EX("b"), EX("blue"),null], + [EX("c"), null, null], + [EX("e"), null, EX('small')], + ]); + }); + it("leaves the rest ungrouped", async () => { + expect(lr.sections[1]).toEqual({ + statements: [ + G1(EX("d"), rdf.type, EX("T2")), + G1(EX("d"), EX("size"), EX("big")), + ], + }); + }); + }); + it("makes a table out of ungrouped triples with the same type", async () => {}); +}); + +// describe("equality", () => { +// test("investigation of https://github.com/rdfjs/N3.js/issues/265", () => { +// const x = namedNode("x"); +// const x2 = namedNode("x"); +// // (NamedNode.prototype as any).hashCode = () => 0; +// // expect((x as any).hashCode()).toEqual((x2 as any).hashCode()) +// expect(x === x2).toBeFalsy(); +// expect(x == x2).toBeFalsy(); +// expect(x.equals(x2)).toBeTruthy(); +// let imap = Immutable.Map(); +// imap = imap.set(x, 11); +// imap = imap.set(x, 22); +// imap = imap.set(x2, 33); +// expect(imap.has(x)).toBeTruthy(); +// expect(imap.has(x2)).toBeTruthy(); +// expect(imap.size).toEqual(1); +// }); +// }); + +// describe("groupByRdfType", () => { +// test("finds multiple graphs", () => {}); +// test("works", async () => { +// const store = new Store(); + +// const parser = new Parser(); +// await new Promise((res, rej) => { +// parser.parse( +// `PREFIX : +// :rs1 a :Foo; :pred1 "obj1" . +// :rs2 a :Foo; :pred1 "obj2" . +// :rs3 a :Bar . +// :rs4 :pred1 "obj4" . +// `, +// (error, quad: Quad, prefixes: Prefixes) => { +// if (quad) { +// store.addQuad(quad); +// } else { +// res(undefined); +// } +// } +// ); +// }); +// const grouped = groupByRdfType(store); +// expect(Array.from(grouped.byType.keys())).toHaveLength(2); +// expect(grouped.byType.get(namedNode("urn:Foo"))).toEqual( +// Immutable.Set([namedNode("urn:rs1"), namedNode("urn:rs2")]) +// ); +// expect(grouped.byType.get(namedNode("urn:Bar"))).toEqual( +// Immutable.Set([namedNode("urn:rs3")]) +// ); +// expect(grouped.untypedSubjs).toEqual([namedNode("urn:rs4")]); +// }); + +// describe("MultiSubjsTypeBlockLayout", () => { +// test("gathers subjs", () => { + +// }); +// test("gathers preds", () => { + +// }); +// test("cells reports filled cells", () => { + +// }); +// }); +// }); diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/Layout.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/Layout.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,200 @@ +// Organize graph data into tables (column orders, etc) for the view layer. + +import Immutable from "immutable"; // mostly using this for the builtin equals() testing, since NamedNode(x)!=NamedNode(x) +import { NamedNode, Quad, Store, Term } from "n3"; +import { rdf } from "./namespaces"; +import { TableDesc, ViewConfig } from "./ViewConfig"; + +type UriSet = Immutable.Set; +export type TypeToSubjs = Immutable.Map; + +// https://github.com/rdfjs/N3.js/issues/265 +(NamedNode.prototype as any).hashCode = () => 0; + +interface ColumnHeader { + rdfType: NamedNode; + pred: NamedNode; +} +export interface AlignedTable { + columnHeaders: ColumnHeader[]; + rows: (Term | null)[][]; // each row is 1 wider than columnHeaders since the 1st element is the subject for that row +} +interface FreeStatements { + statements: Quad[]; +} +export interface LayoutResult { + sections: (AlignedTable | FreeStatements)[]; +} + +class AlignedTableBuilder { + columnPreds = Immutable.List(); + subjRowIndices = Immutable.Map(); + rows: (Term | null)[][] = []; + constructor( + public rdfType: NamedNode /* plus join types, sort instructions */ + ) {} + + addQuad(q: Quad) { + const pred = q.predicate as NamedNode; + const omittedColumn = pred.equals(rdf.type); + if (!this.columnPreds.contains(pred) && !omittedColumn) { + this.columnPreds = this.columnPreds.push(pred); // this is putting cols in random order + this.rows.forEach((r) => r.push(null)); + } + + const predIndex = omittedColumn ? null : this.columnPreds.indexOf(pred); + let rowIndex = this.subjRowIndices.get(q.subject as NamedNode); + if (rowIndex === undefined) { + const newRow = new Array(1 + this.columnPreds.size).fill(null); + newRow[0] = q.subject; + this.rows.push(newRow); + rowIndex = this.rows.length - 1; + this.subjRowIndices = this.subjRowIndices.set( + q.subject as NamedNode, + rowIndex + ); + } + if (predIndex !== null) { + this.rows[rowIndex][1 + predIndex] = q.object; + } + } + + value(): AlignedTable { + this.rows.sort((a, b) => { + const uriA = (a[0] as NamedNode).value, + uriB = (b[0] as NamedNode).value; + return uriA.localeCompare(uriB); + }); + const headers = this.columnPreds + .map((pred) => { + return { rdfType: this.rdfType, pred: pred }; + }) + .toArray(); + return { columnHeaders: headers, rows: this.rows }; + } +} + +function findTypesNeededForTables(viewConfig?: ViewConfig): UriSet { + const typesToGather: NamedNode[] = []; + if (viewConfig) { + viewConfig.tables.forEach((t: TableDesc) => { + typesToGather.push(t.primary); + }); + } + return Immutable.Set(typesToGather); +} + +function findSubjectsWithTypes(graph: Store, typesToGatherSet: UriSet): UriSet { + const subjectsToGather: NamedNode[] = []; + graph.forEach( + (q: Quad) => { + if (typesToGatherSet.contains(q.object as NamedNode)) { + subjectsToGather.push(q.subject as NamedNode); + } + }, + null, + rdf.type, + null, + null + ); + return Immutable.Set(subjectsToGather); +} + +export class Layout { + constructor(public viewConfig?: ViewConfig) {} + plan(graph: Store): LayoutResult { + const typesToGatherSet = findTypesNeededForTables(this.viewConfig); + + const subjectsToGatherSet = findSubjectsWithTypes(graph, typesToGatherSet); + const ungrouped: Quad[] = []; + const vc = this.viewConfig; + const table = + vc && vc.tables.length > 0 + ? new AlignedTableBuilder(vc.tables[0].primary) + : null; + + graph.forEach( + (q: Quad) => { + if (!subjectsToGatherSet.contains(q.subject as NamedNode) || !table) { + ungrouped.push(q); + } else { + table.addQuad(q); + } + }, + null, + null, + null, + null + ); + const res: LayoutResult = { sections: [] }; + if (table) { + res.sections.push(table.value()); + } + res.sections.push({ statements: ungrouped }); + return res; + } +} + +// interface ISP { +// subj: NamedNode; +// pred: NamedNode; +// } +// const SP = Immutable.Record({ +// subj: new NamedNode(""), +// pred: new NamedNode(""), +// }); + +// // One table of rows with a common rdf:type. +// export class MultiSubjsTypeBlockLayout { +// subjs: NamedNode[]; +// preds: NamedNode[]; +// graphCells: Immutable.Map>; +// constructor(graph: Store, byType: TypeToSubjs, table: TableDesc) { +// const subjSet = byType.get(table.primary); +// this.subjs = subjSet ? Array.from(subjSet) : []; +// this.subjs.sort(); + +// let preds = Immutable.Set(); + +// this.graphCells = Immutable.Map>().withMutations( +// (mutGraphCells) => { +// this.subjs.forEach((subj: NamedNode) => { +// graph.forEach( +// (q: Quad) => { +// if (!Util.isNamedNode(q.predicate)) { +// throw new Error(); +// } + +// const pred = q.predicate as NamedNode; +// if (pred.equals(rdf.type)) { +// // the whole block is labeled with the type +// return; +// } +// preds = preds.add(pred); +// const cellKey = this.makeCellKey(subj, pred); +// mutGraphCells.set( +// cellKey, +// mutGraphCells.get(cellKey, Immutable.Set()).add(q.object) +// ); +// }, +// subj, +// null, +// null, +// null +// ); +// }); +// } +// ); +// this.preds = Array.from(preds); +// this.preds.splice(this.preds.indexOf(rdf.type), 1); +// // also pull out label, which should be used on 1st column +// this.preds.sort(); +// } + +// makeCellKey(subj: NamedNode, pred: NamedNode): ISP { +// return SP({ +// subj: subj, +// pred: pred, +// }); +// } +// } diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/ViewConfig.test.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/ViewConfig.test.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,25 @@ +import { Util } from "n3"; +import { ViewConfig } from "./ViewConfig"; + +describe("ViewModel", () => { + it("gets a table description", async () => { + const vc = new ViewConfig(); + + await vc.readFromGraph(` + @prefix ex: . + @prefix demo: . + @prefix rdfs: . + @prefix : . + + <> a ex:View ; rdfs:label "repos" . + <> ex:table demo:table1 . + demo:table1 + ex:primaryType :FilteredNic; + ex:joinType :Traffic . + `); + const NET = Util.prefix("http://bigasterisk.com/netRoutes/ns#"); + + expect(vc.tables[0].primary).toEqual(NET("FilteredNic")); + expect(vc.tables[0].joins).toEqual([NET("Traffic")]); + }); +}); diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/ViewConfig.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/ViewConfig.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,70 @@ +// Load requested view (rdf data) and provide access to it +import { DataFactory, NamedNode, Store } from "n3"; +import { fetchAndParse, n3Graph } from "./fetchAndParse"; +import { EX, RDF } from "./namespaces"; +import { labelOrTail, uriValue } from "./rdf_value"; +const Uri = DataFactory.namedNode; + +function firstElem(seq: Iterable): E { + for (let e of seq) { + return e; + } + throw new Error("no elems"); +} + +export interface TableDesc { + uri: NamedNode; + primary: NamedNode; + joins: NamedNode[]; +} + +export class ViewConfig { + graph: Store; + viewRoot!: NamedNode; + url?: string; + tables: TableDesc[] = []; + + constructor() { + this.graph = new Store(); + } + + async readFromUrl(url: string | "") { + if (!url) { + return; + } + await fetchAndParse(url, this.graph); + + this._read(); + } + + async readFromGraph(n3: string) { + this.graph = await n3Graph(n3); + this._read(); + } + + _read() { + this.viewRoot = firstElem( + this.graph.getSubjects(RDF("type"), EX("View"), null) + ) as NamedNode; + for (let table of this.graph.getObjects(this.viewRoot, EX("table"), null)) { + const tableType = uriValue(this.graph, table, EX("primaryType")); + const joins: NamedNode[] = []; + for (let joinType of this.graph.getObjects(table, EX("joinType"), null)) { + joins.push(joinType as NamedNode); + } + joins.sort(); + this.tables.push({ + uri: table as NamedNode, + primary: tableType, + joins: joins, + }); + } + this.tables.sort(); + } + + label(): string { + return this.url !== undefined + ? labelOrTail(this.graph, Uri(this.url)) + : "unnamed"; + } +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/fetchAndParse.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/fetchAndParse.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,30 @@ +import { Store, Parser, Quad, Prefixes } from "n3"; + +export async function fetchAndParse( + url: string, + store?: Store +): Promise { + const res = await fetch(url); + const body = await res.text(); + return n3Graph(body, store); +} + +export async function n3Graph(n3: string, store?: Store): Promise { + if (store === undefined) { + store = new Store(); + } + + const parser = new Parser({ format: "TriG" }); + await new Promise((res, rej) => { + parser.parse(n3, (error, quad: Quad, prefixes: Prefixes) => { + if (error) rej(error); + if (quad) { + store!.addQuad(quad); + } else { + res(undefined); + } + }); + }); + + return store; +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/graph_queries.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/graph_queries.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,101 @@ +// import { DataFactory, Literal, N3Store, NamedNode, Util } from "n3"; +// const { literal, namedNode } = DataFactory; + +// // i think this one is a worse subset of graphLiteral, below +// export function getStringValue( +// store: N3Store | undefined, +// subj: NamedNode, +// pred: NamedNode, +// defaultValue: string = "" +// ): string { +// if (store === undefined) { +// // this is so you can use the function before you have a graph +// return "..."; +// } +// const objs = store.getObjects(subj, pred, null); +// if (objs.length == 0) { +// return defaultValue; +// } +// return objs[0].value; +// } + +// // workaround for uris that don't have good labels in the graph +// export function labelFromUri( +// uri: NamedNode, +// prefix: string, +// tailsToLabels: { [key: string]: string }, +// defaultLabel: string +// ) { +// let label = defaultLabel === undefined ? uri.value : defaultLabel; +// Object.entries(tailsToLabels).forEach(([tail, useLabel]) => { +// if (uri.equals(namedNode(prefix + tail))) { +// label = useLabel as string; +// } +// }); +// return label; +// } + +// export function graphLiteral( +// store: N3Store, +// subj: NamedNode, +// pred: string, +// notFoundResult?: string +// ): Literal { +// const keep: Array = []; +// store.forEach( +// q => { +// if (!Util.isLiteral(q.object)) { +// throw new Error("non literal found"); +// } +// let seen = false; +// for (let other of keep) { +// // why are we getting multiple matches for the same literal? seems like a bug +// if (other.equals(q.object)) { +// seen = true; +// } +// } +// if (!seen) { +// keep.push(q.object as Literal); +// } +// }, +// subj, +// namedNode(pred), +// null, +// null +// ); +// if (keep.length == 0) { +// return literal(notFoundResult || "(missing)"); +// } +// if (keep.length == 1) { +// return keep[0]; +// } +// console.log(`${subj.value} ${pred} had ${keep.length} objects:`, keep); +// return keep[0]; +// } + +// export function graphUriValue( +// store: N3Store, +// subj: NamedNode, +// pred: string +// ): NamedNode | undefined { +// const keep: Array = []; +// store.forEach( +// q => { +// if (!Util.isNamedNode(q.object)) { +// throw new Error("non uri found"); +// } +// keep.push(q.object as NamedNode); +// }, +// subj, +// namedNode(pred), +// null, +// null +// ); +// if (keep.length == 0) { +// return undefined; +// } +// if (keep.length == 1) { +// return keep[0]; +// } +// throw new Error("found multiple matches for pred"); +// } diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/json_ld_quads.test.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/json_ld_quads.test.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,29 @@ +import { eachJsonLdQuad } from "./json_ld_quads"; +import { Literal, DataFactory } from "n3"; +const { literal } = DataFactory; + +describe("eachJsonLdQuad", () => { + test("finds multiple graphs", () => {}); + test("returns quads", async () => { + let results: Array = []; + await eachJsonLdQuad( + [ + { + "@id": "http://example.com/g1", + "@graph": [ + { + "@id": "http://example.com/s1", + "http://example.com/p1": [{ "@value": "lit1" }] + } + ] + } + ], + (res: any) => results.push(res) + ); + expect(results).toHaveLength(1); + expect(results[0].subject.value).toEqual("http://example.com/s1"); + expect(results[0].predicate.value).toEqual("http://example.com/p1"); + expect((results[0].object as Literal).equals(literal("lit1"))).toBeTruthy(); + expect(results[0].graph.value).toEqual("http://example.com/g1"); + }); +}); diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/json_ld_quads.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/json_ld_quads.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,67 @@ +import * as jsonld from "jsonld"; +import { JsonLd, JsonLdArray } from "jsonld/jsonld-spec"; +import { Quad, NamedNode, DataFactory } from "n3"; +const { literal, quad, namedNode } = DataFactory; + +// const { rdf } = ns; +const rdf = { type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" }; + +function parseObjNode(obj: any) { + if (obj["@id"]) { + return namedNode(obj["@id"]); + } else { + if (obj["@value"] === undefined) { + throw new Error("no @id or @value"); + } + return literal(obj["@value"], obj["@language"] || obj["@type"]); + } +} + +function parsePred( + onQuad: (q: Quad) => void, + graphNode: NamedNode, + subjNode: NamedNode, + predKey: string, + subjGroup: any +) { + let predNode: NamedNode; + if (predKey === "@type") { + subjGroup["@type"].forEach((aType: string) => { + onQuad(quad(subjNode, namedNode(rdf.type), namedNode(aType), graphNode)); + }); + return; + } + predNode = namedNode(predKey); + subjGroup[predKey].forEach(function(obj: any) { + const objNode = parseObjNode(obj); + onQuad(quad(subjNode, predNode, objNode, graphNode)); + }); +} +function parseSubj( + onQuad: (q: Quad) => void, + graphNode: NamedNode, + subjGroup: { [predOrId: string]: any } +) { + const subjNode = namedNode(subjGroup["@id"]); + for (let predKey in subjGroup) { + if (predKey === "@id") { + continue; + } + parsePred(onQuad, graphNode, subjNode, predKey, subjGroup); + } +} +function parseGraph(onQuad: (q: Quad) => void, g: JsonLd) { + var graph = (g as { "@id": string })["@id"]; + var graphNode = namedNode(graph); + (g as { "@graph": JsonLdArray })["@graph"].forEach(subj => { + parseSubj(onQuad, graphNode, subj); + }); +} + +export async function eachJsonLdQuad( + jsonLdObj: object, + onQuad: (q: Quad) => void +) { + const expanded = await jsonld.expand(jsonLdObj); + (expanded as JsonLdArray).forEach((g: JsonLd) => parseGraph(onQuad, g)); +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/namespaces.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/namespaces.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,11 @@ +import { DataFactory, Util } from "n3"; +const { namedNode } = DataFactory; + +export const RDFS = Util.prefix("http://www.w3.org/2000/01/rdf-schema#"); +export const RDF = Util.prefix("http://www.w3.org/1999/02/22-rdf-syntax-ns#"); +export const EX = Util.prefix("http://example.com/"); + +export const rdf = { + type: namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + }; + \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/rdf_value.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/rdf_value.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,46 @@ +import { Store, Term, NamedNode } from "n3"; +import { RDFS } from "./namespaces"; + +function _singleValue(g: Store, s: Term, p: Term): Term { + const quads = g.getQuads(s, p, null, null); + const objs = new Set(quads.map((q) => q.object)); + if (objs.size == 0) { + throw new Error("no value for " + s.value + " " + p.value); + } else if (objs.size == 1) { + const obj = objs.values().next().value; + return obj as Term; + } else { + throw new Error("too many different values: " + JSON.stringify(quads)); + } +} + +export function stringValue(g: Store, s: Term, p: Term): string { + const ret = _singleValue(g, s, p); + if (ret.termType != "Literal") { + throw new Error(`ret=${ret}`); + } + return ret.value as string; +} + +export function uriValue(g: Store, s: Term, p: Term): NamedNode { + const ret = _singleValue(g, s, p); + if (ret.termType != "NamedNode") { + throw new Error(`ret=${ret}`); + } + + return ret; +} + +export function labelOrTail(g: Store, uri: NamedNode): string { + let ret: string; + try { + ret = stringValue(g, uri, RDFS("label")); + } catch (e) { + const words = uri.value.split("/"); + ret = words[words.length - 1]; + } + if (!ret) { + ret = uri.value; + } + return ret; +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/streamed_graph_client.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/streamed_graph_client.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,142 @@ +import { eachJsonLdQuad } from "./json_ld_quads"; +import { Store } from "n3"; + +export class StreamedGraphClient { + // holds a n3 Store, which is synced to a server-side + // store that sends patches over SSE + + onStatus: (msg: string) => void = function (m) {}; + onGraphChanged: () => void = function () {}; + store: Store; + _deletedCount: number = 0; + events!: EventSource; + constructor( + eventsUrl: string, + onGraphChanged: () => void, + onStatus: (status: string) => void, + prefixes: Array>, + staticGraphUrls: Array + ) { + console.log("new StreamedGraph", eventsUrl); + this.onStatus = onStatus; + this.onGraphChanged = onGraphChanged; + this.onStatus("startup..."); + + this.store = new Store(); + + // Object.keys(prefixes).forEach((prefix) => { + // this.store.setPrefix(prefix, prefixes[prefix]); + // }); + + this.connect(eventsUrl); + this.reconnectOnWake(); + + // staticGraphUrls.forEach((url) => { + // fetch(url).then((response) => response.text()) + // .then((body) => { + // // parse with n3, add to output + // }); + // }); + } + + _vacuum() { + // workaround for the growing _ids map + this.store = new Store(this.store.getQuads(null, null, null, null)); + } + + reconnectOnWake() { + // it's not this, which fires on every mouse-in on a browser window, and doesn't seem to work for screen-turned-back-on + //window.addEventListener('focus', function() { this.connect(eventsUrl); }.bind(this)); + } + + connect(eventsUrl: string) { + // need to exit here if this obj has been replaced + + this.onStatus("start connect..."); + this.close(); + if (this.events && this.events.readyState != EventSource.CLOSED) { + this.onStatus("zombie"); + throw new Error("zombie eventsource"); + } + + this.events = new EventSource(eventsUrl, { withCredentials: true }); + + this.events.addEventListener("error", (ev) => { + // todo: this is piling up tons of retries and eventually multiple connections + this.testEventUrl(eventsUrl); + this.onStatus("connection lost- retrying"); + setTimeout(() => { + requestAnimationFrame(() => { + this.connect(eventsUrl); + }); + }, 3000); + }); + + this.events.addEventListener("fullGraph", async (ev) => { + this.onStatus("sync- full graph update"); + await this.replaceFullGraph((ev as MessageEvent).data); + this.onStatus(`synced ${this.store.size}`); + this.onGraphChanged(); + }); + + this.events.addEventListener("patch", async (ev) => { + this.onStatus("sync- updating"); + await this.patchGraph((ev as MessageEvent).data); + window.setTimeout(() => { + this.onStatus(`synced ${this.store.size}`); + }, 60); + this.onGraphChanged(); + }); + this.onStatus("connecting..."); + } + + // these need some locks + async replaceFullGraph(jsonLdText: string) { + this.store = new Store(); + await eachJsonLdQuad( + JSON.parse(jsonLdText), + this.store.addQuad.bind(this.store) + ); + } + + async patchGraph(patchJson: string) { + var patch = JSON.parse(patchJson).patch; + + await eachJsonLdQuad(patch.deletes, (quad) => { + this.store.removeQuad(quad); + this._deletedCount++; + }); + await eachJsonLdQuad(patch.adds, this.store.addQuad.bind(this.store)); + + if (this._deletedCount > 100) { + this._vacuum(); + this._deletedCount = 0; + } + } + + close() { + if (this.events) { + this.events.close(); + } + } + + async testEventUrl(eventsUrl: string): Promise { + return new Promise((resolve, reject) => { + this.onStatus("testing connection"); + fetch(eventsUrl, { + method: "HEAD", + credentials: "include", + }) + .then((value) => { + if (value.status == 403) { + reject(); + return; + } + resolve(); + }) + .catch((err) => { + reject(); + }); + }); + } +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/suffixLabels.test.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/suffixLabels.test.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,70 @@ +import { SuffixLabels } from './suffixLabels'; + +describe('_tailSegments', () => { + it("returns right amount", () => { + expect(SuffixLabels._tailSegments('http://foo/a/bb', 0)).toEqual(''); + expect(SuffixLabels._tailSegments('http://foo/a/bb', 1)).toEqual('bb'); + expect(SuffixLabels._tailSegments('http://foo/a/bb', 2)).toEqual('a/bb'); + expect(SuffixLabels._tailSegments('http://foo/a/bb', 3)).toEqual('foo/a/bb'); + expect(SuffixLabels._tailSegments('http://foo/a/bb', 4)).toEqual('/foo/a/bb'); + expect(SuffixLabels._tailSegments('http://foo/a/bb', 5)).toEqual('http://foo/a/bb'); + }); + it("_tailSegments ok with trailing slash", () => { + expect(SuffixLabels._tailSegments('http://foo/', 0)).toEqual(''); + expect(SuffixLabels._tailSegments('http://foo/', 1)).toEqual(''); + expect(SuffixLabels._tailSegments('http://foo/', 2)).toEqual('foo/'); + }); +}); + +describe("suffixLabels", () => { + const fakeNode = (uri: string) => { return { nominalValue: uri } }; + + it("returns whole url segments", () => { + const suf = new SuffixLabels(); + suf._planDisplayForUri('http://a/b/c/dd'); + suf._planDisplayForUri('http://a/b/c/ee'); + + expect(suf.getLabelForNode('http://a/b/c/dd')).toEqual('dd'); + expect(suf.getLabelForNode('http://a/b/c/ee')).toEqual('ee'); + }); + + it("doesn't treat a repeated uri as a name clash", () => { + const suf = new SuffixLabels(); + suf._planDisplayForUri('http://a/b/c'); + suf._planDisplayForUri('http://a/b/c'); + + expect(suf.getLabelForNode('http://a/b/c')).toEqual('c'); + }); + + it("moves to two segments when needed", () => { + const suf = new SuffixLabels(); + suf._planDisplayForUri('http://a/b/c/d'); + suf._planDisplayForUri('http://a/b/f/d'); + + expect(suf.getLabelForNode('http://a/b/c/d')).toEqual('c/d'); + expect(suf.getLabelForNode('http://a/b/f/d')).toEqual('f/d'); + }); + + it("is ok with clashes at different segment positions", () => { + const suf = new SuffixLabels(); + suf._planDisplayForUri('http://z/z/z/a/b/c'); + suf._planDisplayForUri('http://a/b/c'); + + expect(suf.getLabelForNode('http://z/z/z/a/b/c')).toEqual('z/a/b/c'); + expect(suf.getLabelForNode('http://a/b/c')).toEqual('/a/b/c'); + }); + + it("uses appropriately long suffixes per uri", () => { + const suf = new SuffixLabels(); + suf._planDisplayForUri('http://a/b/c/d/e'); + suf._planDisplayForUri('http://a/b/f/d/e'); + suf._planDisplayForUri('http://a/b/c/g'); + suf._planDisplayForUri('http://a/z'); + + expect(suf.getLabelForNode('http://a/b/c/d/e')).toEqual('c/d/e'); + expect(suf.getLabelForNode('http://a/b/f/d/e')).toEqual('f/d/e'); + expect(suf.getLabelForNode('http://a/b/c/g')).toEqual('g'); + expect(suf.getLabelForNode('http://a/z')).toEqual('z'); + }); + +}); \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/layout/suffixLabels.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/layout/suffixLabels.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,81 @@ +import { Term } from "n3"; + +type SuffixesNode = { usedBy?: string; children: Map }; +type DisplayNode = { label?: string; link?: string }; + +export class SuffixLabels { + displayNodes: Map; + usedSuffixes: SuffixesNode; + constructor() { + this.displayNodes = new Map(); + this.usedSuffixes = { usedBy: undefined, children: new Map() }; + } + + planDisplayForNode(node: Term) { + const uri = node.value; + this._planDisplayForUri(uri); + } + + _planDisplayForUri(uri: string) { + if (this.displayNodes.has(uri)) { + return; + } + + const segments = uri.split("/"); + let curs = this.usedSuffixes; + let label: string | undefined = undefined; + + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (curs.usedBy && curs.usedBy != uri) { + this._prependClashingUri(curs); + } + + if (!curs.children.has(seg)) { + const child: SuffixesNode = { usedBy: undefined, children: new Map() }; + curs.children.set(seg, child); + + if (label === undefined) { + label = SuffixLabels._tailSegments(uri, segments.length - i); + child.usedBy = uri; + } + } + curs = curs.children.get(seg)!; + } + this.displayNodes.set(uri, { label: label }); + } + + _prependClashingUri(curs: SuffixesNode) { + // Claim: When a clash is discovered, only 1 uri needs to + // change its length, and there will be only one child node to + // follow, and the clashing uri can be changed to prepend that + // one child (since we'll see it again if that one wasn't + // enough). + const clashNode: DisplayNode = this.displayNodes.get(curs.usedBy!)!; + const nextLeftSeg = curs.children.entries().next().value; + if (nextLeftSeg[1].usedBy) { + throw new Error("unexpected"); + } + + clashNode.label = nextLeftSeg[0] + "/" + clashNode.label; + nextLeftSeg[1].usedBy = curs.usedBy; + curs.usedBy = undefined; + } + + // a substring to show for this uri + getLabelForNode(node: string) { + const dn = this.displayNodes.get(node); + if (dn === undefined) { + throw new Error(`you never called planDisplayForNode on ${node}`); + } + return dn.label; + } + + static _tailSegments(uri: string, n: number) { + let i = uri.length; + for (let rep = 0; rep < n; rep++) { + i = uri.lastIndexOf("/", i - 1); + } + return uri.substr(i + 1); + } +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/namespaces.ts --- a/src/namespaces.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,11 +0,0 @@ -import { DataFactory, Util } from "n3"; -const { namedNode } = DataFactory; - -export const RDFS = Util.prefix("http://www.w3.org/2000/01/rdf-schema#"); -export const RDF = Util.prefix("http://www.w3.org/1999/02/22-rdf-syntax-ns#"); -export const EX = Util.prefix("http://example.com/"); - -export const rdf = { - type: namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), - }; - \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/rdf_value.ts --- a/src/rdf_value.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,46 +0,0 @@ -import { Store, Term, NamedNode } from "n3"; -import { RDFS } from "./namespaces"; - -function _singleValue(g: Store, s: Term, p: Term): Term { - const quads = g.getQuads(s, p, null, null); - const objs = new Set(quads.map((q) => q.object)); - if (objs.size == 0) { - throw new Error("no value for " + s.value + " " + p.value); - } else if (objs.size == 1) { - const obj = objs.values().next().value; - return obj as Term; - } else { - throw new Error("too many different values: " + JSON.stringify(quads)); - } -} - -export function stringValue(g: Store, s: Term, p: Term): string { - const ret = _singleValue(g, s, p); - if (ret.termType != "Literal") { - throw new Error(`ret=${ret}`); - } - return ret.value as string; -} - -export function uriValue(g: Store, s: Term, p: Term): NamedNode { - const ret = _singleValue(g, s, p); - if (ret.termType != "NamedNode") { - throw new Error(`ret=${ret}`); - } - - return ret; -} - -export function labelOrTail(g: Store, uri: NamedNode): string { - let ret: string; - try { - ret = stringValue(g, uri, RDFS("label")); - } catch (e) { - const words = uri.value.split("/"); - ret = words[words.length - 1]; - } - if (!ret) { - ret = uri.value; - } - return ret; -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/render/NodeDisplay.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/render/NodeDisplay.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,55 @@ +import { html, TemplateResult } from "lit"; +import { Literal, NamedNode, Term, Util } from "n3"; +import { SuffixLabels } from "../layout/suffixLabels"; + +export class NodeDisplay { + labels: SuffixLabels; + constructor(labels: SuffixLabels) { + this.labels = labels; + } + render(n: Term | NamedNode): TemplateResult { + if (Util.isLiteral(n)) { + n = n as Literal; + let dtPart: any = ""; + if (n.datatype && + n.datatype.value != "http://www.w3.org/2001/XMLSchema#string" && // boring + n.datatype.value != + "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" // boring + ) { + dtPart = html` + ^^ ${this.render(n.datatype)} + `; + } + return html` ${n.value}${dtPart} `; + } + + if (Util.isNamedNode(n)) { + n = n as NamedNode; + let shortened = false; + let uriValue: string = n.value; + for (let [long, short] of [ + ["http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:"], + ["http://www.w3.org/2000/01/rdf-schema#", "rdfs:"], + ["http://purl.org/dc/elements/1.1/", "dc:"], + ["http://www.w3.org/2001/XMLSchema#", "xsd:"], + ]) { + if (uriValue.startsWith(long)) { + uriValue = short + uriValue.substr(long.length); + shortened = true; + break; + } + } + if (!shortened) { + let dn: string | undefined = this.labels.getLabelForNode(uriValue); + if (dn === undefined) { + throw new Error(`dn=${dn}`); + } + uriValue = dn; + } + + return html` ${uriValue} `; + } + + return html` [${n.termType} ${n.value}] `; + } +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/render/element.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/render/element.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,150 @@ +import { LitElement, html, render, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { Store } from "n3"; + +import { GraphView } from "./graph_view"; +import { StreamedGraphClient } from "../layout/streamed_graph_client"; +import { style, addFontToRootPage } from "./style"; + +// export * from "./graph_queries"; + +export interface VersionedGraph { + version: number; + store: Store; +} + +@customElement("streamed-graph") +export class StreamedGraph extends LitElement { + @property({ type: String }) + url: string = ""; + @property({ type: String }) + view: string = ""; + @property({ type: Object }) + graph!: VersionedGraph; + + @property({ type: Boolean }) + expanded: boolean = false; + + @property({ type: String }) + status: string = ""; + + sg!: StreamedGraphClient; + graphViewDirty = true; + + static styles = style; + + render() { + const expandAction = this.expanded ? "-" : "+"; + return html` +
+ + StreamedGraph [source]: ${this.status} +
+
+ `; + } + + connectedCallback() { + super.connectedCallback(); + addFontToRootPage(); + const emptyStore = new Store(); + this.graph = { version: -1, store: emptyStore }; + + this._onUrl(this.url); // todo: watch for changes and rebuild + if (this.expanded) { + this.redrawGraph(); + } + } + + toggleExpand() { + this.expanded = !this.expanded; + if (this.expanded) { + this.redrawGraph(); + } else { + this.graphViewDirty = false; + this._graphAreaClose(); + } + } + + redrawGraph() { + this.graphViewDirty = true; + const rl: ()=>Promise = this._redrawLater.bind(this) + requestAnimationFrame(rl); + } + + _onUrl(url: string) { + if (this.sg) { + this.sg.close(); + } + this.sg = new StreamedGraphClient( + url, + this.onGraphChanged.bind(this), + (st) => { + this.status = st; + }, + [], //window.NS, + [] + ); + } + + onGraphChanged() { + this.graph = { + version: this.graph.version + 1, + store: this.sg.store, + }; + if (this.expanded) { + this.redrawGraph(); + } + this.dispatchEvent( + new CustomEvent("graph-changed", { detail: { graph: this.graph } }) + ); + } + + async _redrawLater() { + if (!this.graphViewDirty) return; + + if ((this.graph as VersionedGraph).store && this.graph.store) { + await this._graphAreaShowGraph( + new GraphView(this.url, this.view, this.graph.store) + ); + this.graphViewDirty = false; + } else { + this._graphAreaShowPending(); + } + } + + _graphAreaClose() { + this._setGraphArea(html``); + } + + _graphAreaShowPending() { + this._setGraphArea(html` waiting for data... `); + } + + async _graphAreaShowGraph(graphView: GraphView) { + this._setGraphArea(await graphView.makeTemplate()); + } + + _setGraphArea(tmpl: TemplateResult) { + const el = this.shadowRoot?.getElementById("graphView"); + if (!el) { + return; + } + render(tmpl, el); + } +} + +declare global { + interface HTMLElementTagNameMap { + "streamed-graph": StreamedGraph; + } +} + +// // allow child nodes to combine a few graphs and statics +// // +// // +// // +// // diff -r 4bb8c7775c83 -r 2468f2227d22 src/render/graph_view.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/render/graph_view.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,196 @@ +import { html, TemplateResult } from "lit"; +import { DataFactory, Literal, NamedNode, Quad, Store, Term } from "n3"; +import { NodeDisplay } from "./NodeDisplay"; +import { SuffixLabels } from "../layout/suffixLabels"; +import { Layout } from "../layout/Layout"; +import { TableDesc, ViewConfig } from "../layout/ViewConfig"; + +const { namedNode } = DataFactory; + +// https://github.com/rdfjs/N3.js/issues/265 +if ((Literal.prototype as any).hashCode === undefined) { + (Literal.prototype as any).hashCode = () => 0; +} +if ((NamedNode.prototype as any).hashCode === undefined) { + (NamedNode.prototype as any).hashCode = () => 0; +} +export class GraphView { + url: string; + view: View; + graph: Store; + nodeDisplay: NodeDisplay; + constructor(url: string, viewUrl: string, graph: Store) { + this.url = url; + this.view = new View(viewUrl); + this.graph = graph; + + const labels = new SuffixLabels(); + this._addLabelsForAllTerms(this.graph, labels); + + if (this.view.graph) { + this._addLabelsForAllTerms(this.view.graph, labels); + } + this.nodeDisplay = new NodeDisplay(labels); + } + + _addLabelsForAllTerms(graph: Store, labels: SuffixLabels) { + console.log("_addLabelsForAllTerms"); + + graph.forEach( + (q: Quad) => { + if (q.subject.termType === "NamedNode") { + labels.planDisplayForNode(q.subject); + } + if (q.predicate.termType === "NamedNode") { + labels.planDisplayForNode(q.predicate); + } + if (q.object.termType === "NamedNode") { + labels.planDisplayForNode(q.object); + } + if (q.object.termType === "Literal" && q.object.datatype) { + labels.planDisplayForNode(q.object.datatype); + } + }, + null, + null, + null, + null + ); + } + + _subjPredObjsBlock(subj: NamedNode) { + const columns = predsForSubj(this.graph, subj); + return html` +
+ ${this.nodeDisplay.render(subj)} + +
+ ${columns.map((p) => { + return this._predObjsBlock(subj, p); + })} +
+
+ `; + } + + _objCell(obj: Term) { + return html` +
+ ${this.nodeDisplay.render(obj)} + +
+ `; + } + + _predObjsBlock(subj: NamedNode, pred: NamedNode) { + const objsSet = new Set(); + this.graph.forEach( + (q: Quad) => { + objsSet.add(q.object); + }, + subj, + pred, + null, + null + ); + const objs = Array.from(objsSet.values()); + objs.sort(); + return html` +
+ ${this.nodeDisplay.render(pred)} +
${objs.map(this._objCell.bind(this))}
+
+ `; + } + + _drawObj(obj: Term): TemplateResult { + return html`
${this.nodeDisplay.render(obj)}
`; + } + + _drawColumnHead(pred: NamedNode): TemplateResult { + return html` ${this.nodeDisplay.render(pred)} `; + } + + _thead(layout: MultiSubjsTypeBlockLayout): TemplateResult { + return html` + + + + ${layout.preds.map(this._drawColumnHead.bind(this))} + + + `; + } + + _msbCell(layout: MultiSubjsTypeBlockLayout, subj: NamedNode) { + return (pred: NamedNode): TemplateResult => { + const objs = layout.graphCells.get(layout.makeCellKey(subj, pred)); + if (!objs || !objs.size) { + return html` `; + } + const objsList = Array.from(objs); + objsList.sort(); + return html` ${objsList.map(this._drawObj.bind(this))} `; + }; + } + + _instanceRow(layout: MultiSubjsTypeBlockLayout) { + return (subj: NamedNode): TemplateResult => { + return html` + + ${this.nodeDisplay.render(subj)} + ${layout.preds.map(this._msbCell(layout, subj))} + + `; + }; + } + + _multiSubjsTypeBlock(byType: TypeToSubjs, table: TableDesc) { + const layout = new MultiSubjsTypeBlockLayout(this.graph, byType, table); + + let typeNames = [html`${this.nodeDisplay.render(table.primary)}`]; + if (table.joins) { + typeNames.push(html` joined with [`); + for (let j of table.joins) { + typeNames.push(html`${this.nodeDisplay.render(j)}`); + } + typeNames.push(html`]`); + } + + return html` +
[icon] Resources of type ${typeNames}
+
+ + ${this._thead(layout)} ${layout.subjs.map(this._instanceRow(layout))} +
+
+ `; + } + + async makeTemplate(): Promise { + await this.view.ready; + const { byType, typesPresent, untypedSubjs } = groupByRdfType(this.graph); + let viewTitle = html` (no view)`; + if (this.view.url) { + viewTitle = html` using view + ${this.view.label()}`; + } + const tables = this.view.toplevelTables(typesPresent); + return html` +
+

+ Current graph (${this.url})${viewTitle} +

+
+ +
+ ${tables.map((t: TableDesc) => this._multiSubjsTypeBlock(byType, t))} +
+ ${untypedSubjs.map(this._subjPredObjsBlock.bind(this))} +
+
+ `; + } +} diff -r 4bb8c7775c83 -r 2468f2227d22 src/render/style.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/render/style.ts Sun Mar 13 22:00:30 2022 -0700 @@ -0,0 +1,159 @@ +import { css } from 'lit'; + +export function addFontToRootPage() { + const content = ` + @font-face { + font-family: 'Allerta'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/allerta/v11/TwMO-IAHRlkbx940YnYXTQ.ttf) format('truetype'); + }`; + + if (!document.getElementById('allerta-style')) { + var head = document.head || document.getElementsByTagName('head')[0], + style = document.createElement('style'); + style.id = 'allerta-style'; + style.type = 'text/css'; + style.innerText = content; + head.appendChild(style); + } +} + + +export const style = css` +:host { + display: flex; + flex-direction: column; + padding: 2px 0; +} +#ui { + display: inline-block; + width: 30em; +} +#ui button { + width: 2em; +} +#ui .expander { + display: inline-block; + padding: 3px; +} +#graphView section { + padding: 4px; +} +#graphView .spoGrid { + display: flex; + flex-direction: column; +} +#graphView .subject, +#graphView .predicate { + display: flex; + align-items: baseline; +} +#graphView .predicate, +#graphView .object { + margin-left: 5px; +} +#graphView .literal { + display: inline-block; + margin: 3px; + padding: 4px; +} +#graphView .literalType { + vertical-align: super; + font-size: 80%; +} +#graphView .resource { + display: inline-block; + margin: 2px; + padding: 1px 6px; +} +#graphView .predicate > a::before { + padding-right: 2px; + content: '━'; + font-weight: bolder; + font-size: 125%; +} +#graphView .predicate > a::after { + content: '🠪'; +} +#graphView table.typeBlock { + border-collapse: collapse; +} +#graphView table.typeBlock td { + white-space: nowrap; +} +#graphView table.typeBlock td .literal { + padding-top: 1px; + padding-bottom: 1px; +} +#graphView .typeBlockScroll { + overflow-x: auto; + max-width: 100%; +} +a { + color: #b1b1fd; + text-shadow: 1px 1px 0px rgba(4,0,255,0.58); + text-decoration-color: rgba(0,0,119,0.078); +} +body.rdfBrowsePage { + background: #000; + color: #fff; + font-size: 12px; +} +#ui { + border: 1px solid #808080; + background: #000; + color: #fff; + font-family: 'Allerta', sans-serif; +} +#graphView { + background: #000; + color: #fff; + font-family: 'Allerta', sans-serif; +} +#graphView section { + border: 1px solid #808080; +} +#graphView .subject { + border-top: 1px solid #2f2f2f; +} +#graphView .literal { + border: 1px solid #808080; + border-radius: 9px; + font-size: 115%; + font-family: monospace; +} +#graphView .subject > .node { + border: 2px solid #448d44; +} +#graphView .resource { + border-radius: 6px; + background: #add8e6; +} +#graphView .predicate > a { + color: #e49dfb; +} +#graphView .comment { + color: #008000; +} +#graphView table.typeBlock th { + border: 2px #333 outset; + background: #1f1f1f; +} +#graphView table.typeBlock td { + border: 2px #4a4a4a outset; + background: #2b2b2b; +} +/* +for my pages serving rdf data, not necessarily part of browse/ +*/ +.served-resources { + margin-top: 4em; + padding-top: 1em; + border-top: 1px solid #808080; +} +.served-resources a { + padding-right: 2em; +} + `; \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/streamed_graph_client.ts --- a/src/streamed_graph_client.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,142 +0,0 @@ -import { eachJsonLdQuad } from "./json_ld_quads"; -import { Store } from "n3"; - -export class StreamedGraphClient { - // holds a n3 Store, which is synced to a server-side - // store that sends patches over SSE - - onStatus: (msg: string) => void = function (m) {}; - onGraphChanged: () => void = function () {}; - store: Store; - _deletedCount: number = 0; - events!: EventSource; - constructor( - eventsUrl: string, - onGraphChanged: () => void, - onStatus: (status: string) => void, - prefixes: Array>, - staticGraphUrls: Array - ) { - console.log("new StreamedGraph", eventsUrl); - this.onStatus = onStatus; - this.onGraphChanged = onGraphChanged; - this.onStatus("startup..."); - - this.store = new Store(); - - // Object.keys(prefixes).forEach((prefix) => { - // this.store.setPrefix(prefix, prefixes[prefix]); - // }); - - this.connect(eventsUrl); - this.reconnectOnWake(); - - // staticGraphUrls.forEach((url) => { - // fetch(url).then((response) => response.text()) - // .then((body) => { - // // parse with n3, add to output - // }); - // }); - } - - _vacuum() { - // workaround for the growing _ids map - this.store = new Store(this.store.getQuads(null, null, null, null)); - } - - reconnectOnWake() { - // it's not this, which fires on every mouse-in on a browser window, and doesn't seem to work for screen-turned-back-on - //window.addEventListener('focus', function() { this.connect(eventsUrl); }.bind(this)); - } - - connect(eventsUrl: string) { - // need to exit here if this obj has been replaced - - this.onStatus("start connect..."); - this.close(); - if (this.events && this.events.readyState != EventSource.CLOSED) { - this.onStatus("zombie"); - throw new Error("zombie eventsource"); - } - - this.events = new EventSource(eventsUrl, { withCredentials: true }); - - this.events.addEventListener("error", (ev) => { - // todo: this is piling up tons of retries and eventually multiple connections - this.testEventUrl(eventsUrl); - this.onStatus("connection lost- retrying"); - setTimeout(() => { - requestAnimationFrame(() => { - this.connect(eventsUrl); - }); - }, 3000); - }); - - this.events.addEventListener("fullGraph", async (ev) => { - this.onStatus("sync- full graph update"); - await this.replaceFullGraph((ev as MessageEvent).data); - this.onStatus(`synced ${this.store.size}`); - this.onGraphChanged(); - }); - - this.events.addEventListener("patch", async (ev) => { - this.onStatus("sync- updating"); - await this.patchGraph((ev as MessageEvent).data); - window.setTimeout(() => { - this.onStatus(`synced ${this.store.size}`); - }, 60); - this.onGraphChanged(); - }); - this.onStatus("connecting..."); - } - - // these need some locks - async replaceFullGraph(jsonLdText: string) { - this.store = new Store(); - await eachJsonLdQuad( - JSON.parse(jsonLdText), - this.store.addQuad.bind(this.store) - ); - } - - async patchGraph(patchJson: string) { - var patch = JSON.parse(patchJson).patch; - - await eachJsonLdQuad(patch.deletes, (quad) => { - this.store.removeQuad(quad); - this._deletedCount++; - }); - await eachJsonLdQuad(patch.adds, this.store.addQuad.bind(this.store)); - - if (this._deletedCount > 100) { - this._vacuum(); - this._deletedCount = 0; - } - } - - close() { - if (this.events) { - this.events.close(); - } - } - - async testEventUrl(eventsUrl: string): Promise { - return new Promise((resolve, reject) => { - this.onStatus("testing connection"); - fetch(eventsUrl, { - method: "HEAD", - credentials: "include", - }) - .then((value) => { - if (value.status == 403) { - reject(); - return; - } - resolve(); - }) - .catch((err) => { - reject(); - }); - }); - } -} diff -r 4bb8c7775c83 -r 2468f2227d22 src/style.ts --- a/src/style.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,159 +0,0 @@ -import { css } from 'lit'; - -export function addFontToRootPage() { - const content = ` - @font-face { - font-family: 'Allerta'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://fonts.gstatic.com/s/allerta/v11/TwMO-IAHRlkbx940YnYXTQ.ttf) format('truetype'); - }`; - - if (!document.getElementById('allerta-style')) { - var head = document.head || document.getElementsByTagName('head')[0], - style = document.createElement('style'); - style.id = 'allerta-style'; - style.type = 'text/css'; - style.innerText = content; - head.appendChild(style); - } -} - - -export const style = css` -:host { - display: flex; - flex-direction: column; - padding: 2px 0; -} -#ui { - display: inline-block; - width: 30em; -} -#ui button { - width: 2em; -} -#ui .expander { - display: inline-block; - padding: 3px; -} -#graphView section { - padding: 4px; -} -#graphView .spoGrid { - display: flex; - flex-direction: column; -} -#graphView .subject, -#graphView .predicate { - display: flex; - align-items: baseline; -} -#graphView .predicate, -#graphView .object { - margin-left: 5px; -} -#graphView .literal { - display: inline-block; - margin: 3px; - padding: 4px; -} -#graphView .literalType { - vertical-align: super; - font-size: 80%; -} -#graphView .resource { - display: inline-block; - margin: 2px; - padding: 1px 6px; -} -#graphView .predicate > a::before { - padding-right: 2px; - content: '━'; - font-weight: bolder; - font-size: 125%; -} -#graphView .predicate > a::after { - content: '🠪'; -} -#graphView table.typeBlock { - border-collapse: collapse; -} -#graphView table.typeBlock td { - white-space: nowrap; -} -#graphView table.typeBlock td .literal { - padding-top: 1px; - padding-bottom: 1px; -} -#graphView .typeBlockScroll { - overflow-x: auto; - max-width: 100%; -} -a { - color: #b1b1fd; - text-shadow: 1px 1px 0px rgba(4,0,255,0.58); - text-decoration-color: rgba(0,0,119,0.078); -} -body.rdfBrowsePage { - background: #000; - color: #fff; - font-size: 12px; -} -#ui { - border: 1px solid #808080; - background: #000; - color: #fff; - font-family: 'Allerta', sans-serif; -} -#graphView { - background: #000; - color: #fff; - font-family: 'Allerta', sans-serif; -} -#graphView section { - border: 1px solid #808080; -} -#graphView .subject { - border-top: 1px solid #2f2f2f; -} -#graphView .literal { - border: 1px solid #808080; - border-radius: 9px; - font-size: 115%; - font-family: monospace; -} -#graphView .subject > .node { - border: 2px solid #448d44; -} -#graphView .resource { - border-radius: 6px; - background: #add8e6; -} -#graphView .predicate > a { - color: #e49dfb; -} -#graphView .comment { - color: #008000; -} -#graphView table.typeBlock th { - border: 2px #333 outset; - background: #1f1f1f; -} -#graphView table.typeBlock td { - border: 2px #4a4a4a outset; - background: #2b2b2b; -} -/* -for my pages serving rdf data, not necessarily part of browse/ -*/ -.served-resources { - margin-top: 4em; - padding-top: 1em; - border-top: 1px solid #808080; -} -.served-resources a { - padding-right: 2em; -} - `; \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/suffixLabels.test.ts --- a/src/suffixLabels.test.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -import { SuffixLabels } from './suffixLabels'; - -describe('_tailSegments', () => { - it("returns right amount", () => { - expect(SuffixLabels._tailSegments('http://foo/a/bb', 0)).toEqual(''); - expect(SuffixLabels._tailSegments('http://foo/a/bb', 1)).toEqual('bb'); - expect(SuffixLabels._tailSegments('http://foo/a/bb', 2)).toEqual('a/bb'); - expect(SuffixLabels._tailSegments('http://foo/a/bb', 3)).toEqual('foo/a/bb'); - expect(SuffixLabels._tailSegments('http://foo/a/bb', 4)).toEqual('/foo/a/bb'); - expect(SuffixLabels._tailSegments('http://foo/a/bb', 5)).toEqual('http://foo/a/bb'); - }); - it("_tailSegments ok with trailing slash", () => { - expect(SuffixLabels._tailSegments('http://foo/', 0)).toEqual(''); - expect(SuffixLabels._tailSegments('http://foo/', 1)).toEqual(''); - expect(SuffixLabels._tailSegments('http://foo/', 2)).toEqual('foo/'); - }); -}); - -describe("suffixLabels", () => { - const fakeNode = (uri: string) => { return { nominalValue: uri } }; - - it("returns whole url segments", () => { - const suf = new SuffixLabels(); - suf._planDisplayForUri('http://a/b/c/dd'); - suf._planDisplayForUri('http://a/b/c/ee'); - - expect(suf.getLabelForNode('http://a/b/c/dd')).toEqual('dd'); - expect(suf.getLabelForNode('http://a/b/c/ee')).toEqual('ee'); - }); - - it("doesn't treat a repeated uri as a name clash", () => { - const suf = new SuffixLabels(); - suf._planDisplayForUri('http://a/b/c'); - suf._planDisplayForUri('http://a/b/c'); - - expect(suf.getLabelForNode('http://a/b/c')).toEqual('c'); - }); - - it("moves to two segments when needed", () => { - const suf = new SuffixLabels(); - suf._planDisplayForUri('http://a/b/c/d'); - suf._planDisplayForUri('http://a/b/f/d'); - - expect(suf.getLabelForNode('http://a/b/c/d')).toEqual('c/d'); - expect(suf.getLabelForNode('http://a/b/f/d')).toEqual('f/d'); - }); - - it("is ok with clashes at different segment positions", () => { - const suf = new SuffixLabels(); - suf._planDisplayForUri('http://z/z/z/a/b/c'); - suf._planDisplayForUri('http://a/b/c'); - - expect(suf.getLabelForNode('http://z/z/z/a/b/c')).toEqual('z/a/b/c'); - expect(suf.getLabelForNode('http://a/b/c')).toEqual('/a/b/c'); - }); - - it("uses appropriately long suffixes per uri", () => { - const suf = new SuffixLabels(); - suf._planDisplayForUri('http://a/b/c/d/e'); - suf._planDisplayForUri('http://a/b/f/d/e'); - suf._planDisplayForUri('http://a/b/c/g'); - suf._planDisplayForUri('http://a/z'); - - expect(suf.getLabelForNode('http://a/b/c/d/e')).toEqual('c/d/e'); - expect(suf.getLabelForNode('http://a/b/f/d/e')).toEqual('f/d/e'); - expect(suf.getLabelForNode('http://a/b/c/g')).toEqual('g'); - expect(suf.getLabelForNode('http://a/z')).toEqual('z'); - }); - -}); \ No newline at end of file diff -r 4bb8c7775c83 -r 2468f2227d22 src/suffixLabels.ts --- a/src/suffixLabels.ts Sun Mar 13 21:57:52 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,81 +0,0 @@ -import { Term } from "n3"; - -type SuffixesNode = { usedBy?: string; children: Map }; -type DisplayNode = { label?: string; link?: string }; - -export class SuffixLabels { - displayNodes: Map; - usedSuffixes: SuffixesNode; - constructor() { - this.displayNodes = new Map(); - this.usedSuffixes = { usedBy: undefined, children: new Map() }; - } - - planDisplayForNode(node: Term) { - const uri = node.value; - this._planDisplayForUri(uri); - } - - _planDisplayForUri(uri: string) { - if (this.displayNodes.has(uri)) { - return; - } - - const segments = uri.split("/"); - let curs = this.usedSuffixes; - let label: string | undefined = undefined; - - for (let i = segments.length - 1; i >= 0; i--) { - const seg = segments[i]; - if (curs.usedBy && curs.usedBy != uri) { - this._prependClashingUri(curs); - } - - if (!curs.children.has(seg)) { - const child: SuffixesNode = { usedBy: undefined, children: new Map() }; - curs.children.set(seg, child); - - if (label === undefined) { - label = SuffixLabels._tailSegments(uri, segments.length - i); - child.usedBy = uri; - } - } - curs = curs.children.get(seg)!; - } - this.displayNodes.set(uri, { label: label }); - } - - _prependClashingUri(curs: SuffixesNode) { - // Claim: When a clash is discovered, only 1 uri needs to - // change its length, and there will be only one child node to - // follow, and the clashing uri can be changed to prepend that - // one child (since we'll see it again if that one wasn't - // enough). - const clashNode: DisplayNode = this.displayNodes.get(curs.usedBy!)!; - const nextLeftSeg = curs.children.entries().next().value; - if (nextLeftSeg[1].usedBy) { - throw new Error("unexpected"); - } - - clashNode.label = nextLeftSeg[0] + "/" + clashNode.label; - nextLeftSeg[1].usedBy = curs.usedBy; - curs.usedBy = undefined; - } - - // a substring to show for this uri - getLabelForNode(node: string) { - const dn = this.displayNodes.get(node); - if (dn === undefined) { - throw new Error(`you never called planDisplayForNode on ${node}`); - } - return dn.label; - } - - static _tailSegments(uri: string, n: number) { - let i = uri.length; - for (let rep = 0; rep < n; rep++) { - i = uri.lastIndexOf("/", i - 1); - } - return uri.substr(i + 1); - } -}