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
32 changes: 32 additions & 0 deletions public/app/sparql-pattern-visualizer/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @file constants.js
* @description Shared constants and defaults.
*/

export const debuggerConsoleLogEnabled = true;

export const DEFAULT_QUERY = `PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>

SELECT ?p ?name WHERE {
?p a foaf:Person .
?p foaf:name ?name .
OPTIONAL { ?p skos:definition ?def . }
FILTER(isLiteral(?name))
}
`;

/**
* Known “annotation-ish” predicates that commonly point to literals.
* (MVP heuristic; can be made configurable.)
*/
export const KNOWN_ANNOTATION_PREDICATE_IRIS = new Set([
"http://www.w3.org/2000/01/rdf-schema#label",
"http://www.w3.org/2000/01/rdf-schema#comment",
"http://purl.org/dc/terms/title",
"http://purl.org/dc/elements/1.1/title",
"http://www.w3.org/2004/02/skos/core#prefLabel",
"http://www.w3.org/2004/02/skos/core#altLabel",
"http://www.w3.org/2004/02/skos/core#definition"
]);
204 changes: 204 additions & 0 deletions public/app/sparql-pattern-visualizer/core_graph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* @file core_graph.js
* @description Build a reusable graph model (nodes/edges + highlights) from a SPARQL.js AST.
*/

import { KNOWN_ANNOTATION_PREDICATE_IRIS } from "./constants.js";
import { termKey, termLabel } from "./core_terms.js";

/**
* @typedef {Object} GraphNode
* @property {string} id
* @property {string} label
* @property {"variable"|"iri"|"blank"|"literal"} kind
* @property {"class"|"individual"|"literal"|"variable"|"unknown"} category
* @property {boolean=} isSelectedVar
*/

/**
* @typedef {Object} GraphEdge
* @property {string} id
* @property {string} source
* @property {string} target
* @property {string} label
* @property {"rdfType"|"objectProp"|"datatypeProp"|"annotationProp"|"path"} category
* @property {"none"|"insert"|"delete"} effect
*/

/**
* @typedef {Object} GraphModel
* @property {string} queryType
* @property {Record<string,string>} prefixes
* @property {GraphNode[]} nodes
* @property {GraphEdge[]} edges
* @property {number} whereTripleCount
*/

/**
* Extract variables returned by SELECT (MVP).
* @param {any} ast
* @returns {Set<string>} variable keys like "var:?x"
*/
export function extractReturnedVariableKeys(ast) {
const out = new Set();
if (!ast || ast.queryType !== "SELECT") return out;
if (ast.variables === "*" || !Array.isArray(ast.variables)) return out;

for (const v of ast.variables) {
// SPARQL.js uses RDF/JS terms; variables are {termType:"Variable", value:"x"}
if (v?.termType === "Variable") out.add(`var:?${v.value}`);
}
return out;
}

/**
* Flatten WHERE patterns into an array of triple objects (MVP: BGP + OPTIONAL/UNION recursion).
* @param {any[]} whereArr
* @returns {any[]} triples with {subject,predicate,object}
*/
export function flattenWhereTriples(whereArr) {
const triples = [];
const patterns = Array.isArray(whereArr) ? whereArr : [];

for (const p of patterns) {
if (!p || typeof p !== "object") continue;

if (p.type === "bgp" && Array.isArray(p.triples)) {
triples.push(...p.triples);
continue;
}

if (p.type === "optional" && Array.isArray(p.patterns)) {
triples.push(...flattenWhereTriples(p.patterns));
continue;
}

if (p.type === "union" && Array.isArray(p.patterns)) {
for (const branch of p.patterns) {
triples.push(...flattenWhereTriples(branch));
}
continue;
}

if (p.type === "group" && Array.isArray(p.patterns)) {
triples.push(...flattenWhereTriples(p.patterns));
continue;
}

if (p.type === "graph" && Array.isArray(p.patterns)) {
triples.push(...flattenWhereTriples(p.patterns));
continue;
}
}

return triples;
}

/**
* Infer node category based on rdf:type usage (MVP heuristic).
* @param {Map<string, GraphNode>} nodesById
* @param {GraphEdge[]} edges
*/
export function applyTypeHeuristics(nodesById, edges) {
for (const e of edges) {
if (e.category !== "rdfType") continue;
const subj = nodesById.get(e.source);
const obj = nodesById.get(e.target);
if (obj && obj.kind === "iri") obj.category = "class";
if (subj && (subj.kind === "iri" || subj.kind === "variable" || subj.kind === "blank")) {
if (subj.category === "unknown") subj.category = "individual";
}
}
}

/**
* Determine edge category from predicate/object term types and annotation predicate list.
* @param {any} predicateTerm
* @param {any} objectTerm
* @returns {"rdfType"|"objectProp"|"datatypeProp"|"annotationProp"|"path"}
*/
export function classifyEdge(predicateTerm, objectTerm) {
const predIri = predicateTerm?.termType === "NamedNode" ? predicateTerm.value : null;

if (predIri === "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") return "rdfType";

// Property path objects in SPARQL.js are not NamedNode terms (MVP: treat separately)
if (predicateTerm && predicateTerm.termType == null && typeof predicateTerm === "object") return "path";

if (objectTerm?.termType === "Literal") {
if (predIri && KNOWN_ANNOTATION_PREDICATE_IRIS.has(predIri)) return "annotationProp";
return "datatypeProp";
}
return "objectProp";
}

/**
* Build GraphModel from a SPARQL.js AST (MVP: WHERE + SELECT highlights).
* @param {any} ast
* @returns {GraphModel}
*/
export function buildGraphModel(ast) {
const prefixes = ast?.prefixes || {};
const queryType = ast?.queryType || ast?.type || "UNKNOWN";

const returnedVarKeys = extractReturnedVariableKeys(ast);
const whereTriples = flattenWhereTriples(ast?.where);

const nodesById = new Map();
/** @type {GraphEdge[]} */
const edges = [];

const ensureNode = (term) => {
const id = termKey(term);
if (nodesById.has(id)) return id;

let kind = "iri";
if (term?.termType === "Variable") kind = "variable";
else if (term?.termType === "BlankNode") kind = "blank";
else if (term?.termType === "Literal") kind = "literal";
else if (term?.termType === "NamedNode") kind = "iri";

const node = {
id,
label: termLabel(term, prefixes),
kind,
category: kind === "literal" ? "literal" : (kind === "variable" ? "variable" : "unknown"),
isSelectedVar: returnedVarKeys.has(id)
};

nodesById.set(id, node);
return id;
};

for (const t of whereTriples) {
const s = ensureNode(t.subject);
const o = ensureNode(t.object);

const edgeCategory = classifyEdge(t.predicate, t.object);
const predLabel =
t.predicate?.termType === "NamedNode"
? termLabel(t.predicate, prefixes)
: (edgeCategory === "path" ? "[path]" : "[predicate]");

const edgeId = `e:${s}::${predLabel}::${o}::${edges.length}`;

edges.push({
id: edgeId,
source: s,
target: o,
label: predLabel,
category: edgeCategory,
effect: "none"
});
}

applyTypeHeuristics(nodesById, edges);

return {
queryType,
prefixes,
nodes: Array.from(nodesById.values()),
edges,
whereTripleCount: whereTriples.length
};
}
26 changes: 26 additions & 0 deletions public/app/sparql-pattern-visualizer/core_parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @file core_parse.js
* @description Parse SPARQL text into SPARQL.js AST.
*/

import { logError } from "./log.js";

/**
* Parse SPARQL query text into a SPARQL.js AST.
* Requires a browser-bundled `window.sparqljs`.
* @param {string} queryText
* @returns {any} SPARQL.js AST
* @throws {Error}
*/
export function parseSparqlToAst(queryText) {
try {
if (!window.sparqljs?.Parser) {
throw new Error("sparqljs Parser not found on window. Did you load vendor/sparqljs.umd.js?");
}
const parser = new window.sparqljs.Parser({ skipValidation: false });
return parser.parse(String(queryText ?? ""));
} catch (err) {
logError("parseSparqlToAst.failed", err, { queryText });
throw err;
}
}
85 changes: 85 additions & 0 deletions public/app/sparql-pattern-visualizer/core_terms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @file core_terms.js
* @description Pure helpers for RDF/JS term handling, CURIE compaction, labels, and keys.
*/

/**
* @typedef {Object} RdfJsTerm
* @property {string} termType
* @property {string} value
* @property {string=} language
* @property {RdfJsTerm=} datatype
*/

/**
* Create a stable key for a term for node IDs.
* @param {RdfJsTerm} term
* @returns {string}
*/
export function termKey(term) {
if (!term || typeof term !== "object") return "term:unknown";
if (term.termType === "Variable") return `var:?${term.value}`;
if (term.termType === "BlankNode") return `bnode:${term.value}`;
if (term.termType === "NamedNode") return `iri:${term.value}`;
if (term.termType === "Literal") {
const dt = term.datatype?.value ?? "";
const lang = term.language ?? "";
return `lit:${term.value}|${lang}|${dt}`;
}
return `term:${term.termType}:${term.value}`;
}

/**
* Choose the best prefix mapping for a given IRI.
* Prefers the *longest* namespace match to avoid overly-broad prefixes.
* @param {string} iri
* @param {Record<string,string>} prefixes
* @returns {{prefix: string, namespace: string}|null}
*/
export function bestPrefixForIri(iri, prefixes) {
const entries = Object.entries(prefixes || {});
let best = null;

for (const [pfx, ns] of entries) {
if (typeof ns !== "string") continue;
if (!iri.startsWith(ns)) continue;
if (!best || ns.length > best.namespace.length) best = { prefix: pfx, namespace: ns };
}
return best;
}

/**
* Compact an IRI to CURIE form if possible, otherwise return the IRI.
* @param {string} iri
* @param {Record<string,string>} prefixes
* @returns {string}
*/
export function compactIri(iri, prefixes) {
const best = bestPrefixForIri(iri, prefixes);
if (!best) return iri;
const local = iri.slice(best.namespace.length);
const pfx = best.prefix === "" ? ":" : `${best.prefix}:`;
return `${pfx}${local}`;
}

/**
* Create a human-readable label for a term.
* @param {RdfJsTerm} term
* @param {Record<string,string>} prefixes
* @returns {string}
*/
export function termLabel(term, prefixes) {
if (!term || typeof term !== "object") return "<?>";

if (term.termType === "Variable") return `?${term.value}`;
if (term.termType === "BlankNode") return `_:${term.value}`;
if (term.termType === "NamedNode") return compactIri(term.value, prefixes);

if (term.termType === "Literal") {
const lang = term.language ? `@${term.language}` : "";
const dt = term.datatype?.value ? `^^${compactIri(term.datatype.value, prefixes)}` : "";
return `"${term.value}"${lang}${dt}`;
}

return term.value ?? "<?>"; // fallback
}
29 changes: 29 additions & 0 deletions public/app/sparql-pattern-visualizer/log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @file log.js
* @description Console logging helpers with a single kill-switch.
*/

import { debuggerConsoleLogEnabled } from "./constants.js";

/**
* Log an event if logging is enabled.
* @param {string} eventName
* @param {any} payload
*/
export function logEvent(eventName, payload) {
if (!debuggerConsoleLogEnabled) return;
// eslint-disable-next-line no-console
console.log(`[sviz] ${eventName}`, payload ?? "");
}

/**
* Log an error if logging is enabled.
* @param {string} eventName
* @param {Error|any} err
* @param {any} context
*/
export function logError(eventName, err, context) {
if (!debuggerConsoleLogEnabled) return;
// eslint-disable-next-line no-console
console.error(`[sviz] ${eventName}`, err, context ?? "");
}
Loading
Loading