From 53c2f320c6b8a25febc0fe7af815d8cdb34cff64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Fri, 28 Aug 2026 17:22:53 +0200 Subject: [PATCH 1/8] Display "Orders handled by this employee" view in table mode Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg --- demo/northwind-traders/admin/model/ns.ttl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index c46c722..3f5e351 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -1,5 +1,6 @@ @prefix : <#> . @prefix ldh: . +@prefix ac: . @prefix rdfs: . @prefix owl: . @prefix sp: . @@ -263,6 +264,7 @@ schema:broker ldh:inverseView :OrdersHandledByEmployee . :OrdersHandledByEmployee a ldh:View ; dct:title "Orders handled by this employee" ; spin:query :SelectOrdersHandledByEmployee ; + ac:mode ac:TableMode ; rdfs:isDefinedBy : . :SelectOrdersHandledByEmployee a sp:Select ; From 0371bef4f5c2a61a5fb202aa4e6aa6c44a1e7b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 29 Aug 2026 11:16:21 +0200 Subject: [PATCH 2/8] Borrow order master-detail UI ideas from Power Apps Northwind sample - Line items table on order pages via forward ldh:view (detail gallery) - Order status (OrderDelivered/OrderProcessing/OrderProblem) derived from shipped vs required date; shipped date and freight re-emitted - Line item extended prices (schema:totalPrice) computed from the existing quantity/unitPrice/discount columns - Orders gallery first on the container page, newest-first, with customer/broker/date/status columns; narrative blocks rewritten; buggy sales-by-region chart (duplicate ldh:seriesVarName) removed - Inverse views upgraded to TableMode with richer columns; new "Orders shipped by this shipper" panel; showWhenEmpty false - KPI row (orders/revenue/avg order value) on the root dashboard - Fix unbound ?deliveryLocation triple in orders.rq The source CSVs stay stock Northwind - no orderTotal or productName columns are added. An order total is the sum of its line items and a product name lives in products.csv, so neither belongs in the source data; the row-at-a-time importer cannot aggregate or join, but that is a constraint to design around rather than to denormalise away. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg --- demo/northwind-traders/admin/model/ns.ttl | 112 ++++++++++++++++-- demo/northwind-traders/orders.ttl | 89 ++++---------- .../northwind-traders/orders/order_details.rq | 6 +- demo/northwind-traders/orders/orders.rq | 8 +- demo/northwind-traders/root.ttl | 41 +++++-- 5 files changed, 174 insertions(+), 82 deletions(-) diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index 3f5e351..081ac43 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -131,9 +131,15 @@ schema:sponsor a owl:ObjectProperty ; schema:orderedItem a owl:ObjectProperty ; rdfs:label "Ordered item" ; + rdfs:domain schema:Order ; rdfs:range schema:Product ; rdfs:isDefinedBy : . +schema:orderStatus a owl:ObjectProperty ; + rdfs:label "Order status" ; + rdfs:domain schema:Order ; + rdfs:isDefinedBy : . + schema:name a owl:DatatypeProperty ; rdfs:label "Name" ; rdfs:isDefinedBy : . @@ -223,6 +229,15 @@ schema:price a owl:DatatypeProperty ; rdfs:label "Price" ; rdfs:isDefinedBy : . +schema:totalPrice a owl:DatatypeProperty ; + rdfs:label "Total price" ; + rdfs:isDefinedBy : . + +schema:availableFrom a owl:DatatypeProperty ; + rdfs:label "Shipped date" ; + rdfs:domain schema:ParcelDelivery ; + rdfs:isDefinedBy : . + schema:orderDate a owl:DatatypeProperty ; rdfs:label "Order date" ; rdfs:domain schema:Order ; @@ -265,6 +280,7 @@ schema:broker ldh:inverseView :OrdersHandledByEmployee . dct:title "Orders handled by this employee" ; spin:query :SelectOrdersHandledByEmployee ; ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; rdfs:isDefinedBy : . :SelectOrdersHandledByEmployee a sp:Select ; @@ -275,9 +291,13 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:broker $about } + { ?order schema:broker $about ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status ; + schema:totalPrice ?total + } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . @@ -290,6 +310,8 @@ schema:customer ldh:inverseView :OrdersFromCustomer . :OrdersFromCustomer a ldh:View ; dct:title "Orders from this customer" ; spin:query :SelectOrdersFromCustomer ; + ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; rdfs:isDefinedBy : . :SelectOrdersFromCustomer a sp:Select ; @@ -300,9 +322,13 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:customer $about } + { ?order schema:customer $about ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status ; + schema:totalPrice ?total + } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . @@ -315,6 +341,8 @@ schema:provider ldh:inverseView :ProductsFromSupplier . :ProductsFromSupplier a ldh:View ; dct:title "Products supplied by this supplier" ; spin:query :SelectProductsFromSupplier ; + ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; rdfs:isDefinedBy : . :SelectProductsFromSupplier a sp:Select ; @@ -325,12 +353,46 @@ PREFIX schema: SELECT DISTINCT ?product WHERE { GRAPH ?graph - { ?product schema:provider $about } + { ?product a schema:Product ; + schema:provider $about ; + schema:category ?category ; + schema:price ?price + } } ORDER BY ?product """ ; rdfs:isDefinedBy : . +# orders shipped by this shipper + +schema:provider ldh:inverseView :OrdersShippedByShipper . + +:OrdersShippedByShipper a ldh:View ; + dct:title "Orders shipped by this shipper" ; + spin:query :SelectOrdersShippedByShipper ; + ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; + rdfs:isDefinedBy : . + +:SelectOrdersShippedByShipper a sp:Select ; + rdfs:label "Select orders shipped by shipper" ; + sp:text """ +PREFIX schema: + +SELECT DISTINCT ?order +WHERE + { GRAPH ?graph + { ?order schema:orderDelivery ?delivery ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status ; + schema:totalPrice ?total . + ?delivery schema:provider $about + } + } +ORDER BY DESC(?orderDate) +""" ; + rdfs:isDefinedBy : . + # containedInPlace # cities in this region @@ -340,6 +402,8 @@ schema:containedInPlace ldh:inverseView :CitiesInRegion . :CitiesInRegion a ldh:View ; dct:title "Cities in this region" ; spin:query :SelectCitiesInRegion ; + ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; rdfs:isDefinedBy : . :SelectCitiesInRegion a sp:Select ; @@ -358,6 +422,35 @@ ORDER BY ?city # orderedItem +# line items of this order (forward view — the master-detail "detail gallery") + +schema:orderedItem ldh:view :OrderLineItems . + +:OrderLineItems a ldh:View ; + dct:title "Line items" ; + spin:query :SelectOrderLineItems ; + ac:mode ac:TableMode ; + rdfs:isDefinedBy : . + +:SelectOrderLineItems a sp:Select ; + rdfs:label "Select line items of order" ; + sp:text """ +PREFIX schema: + +SELECT DISTINCT ?item +WHERE + { GRAPH ?graph + { $about schema:orderedItem ?item . + ?item schema:orderedItem ?product ; + schema:orderQuantity ?quantity ; + schema:price ?price ; + schema:totalPrice ?lineTotal + } + } +ORDER BY ?item +""" ; + rdfs:isDefinedBy : . + # orders containing this product schema:orderedItem ldh:inverseView :OrdersContainingProduct . @@ -365,6 +458,8 @@ schema:orderedItem ldh:inverseView :OrdersContainingProduct . :OrdersContainingProduct a ldh:View ; dct:title "Orders containing this product" ; spin:query :SelectOrdersContainingProduct ; + ac:mode ac:TableMode ; + ldh:showWhenEmpty false ; rdfs:isDefinedBy : . :SelectOrdersContainingProduct a sp:Select ; @@ -375,10 +470,13 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:orderedItem ?orderItem . + { ?order schema:orderedItem ?orderItem ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status ; + schema:totalPrice ?total . ?orderItem schema:orderedItem $about } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . diff --git a/demo/northwind-traders/orders.ttl b/demo/northwind-traders/orders.ttl index e9f32e5..1f6d189 100644 --- a/demo/northwind-traders/orders.ttl +++ b/demo/northwind-traders/orders.ttl @@ -10,12 +10,22 @@ <> a dh:Container ; dct:title "Orders" ; rdf:_1 <#orders-intro> ; - rdf:_2 <#orders-over-time-block> ; - rdf:_3 <#geographic-intro> ; - rdf:_4 <#orders-by-country-block> ; - rdf:_5 <#order-trends-summary> ; - rdf:_6 <#sales-by-region-per-year-block> ; - rdf:_7 <#select-orders> . + rdf:_2 <#select-orders> ; + rdf:_3 <#analytics-intro> ; + rdf:_4 <#orders-over-time-block> ; + rdf:_5 <#geographic-intro> ; + rdf:_6 <#orders-by-country-block> . + + # Intro XHTML + <#orders-intro> a ldh:XHTML ; + rdf:value """
+

The order book

+

Every order links a customer, the employee who brokered it, the shipper that delivered it, and the + products it contains. The gallery below lists all 830 orders, newest first, with their status and + total value — sort any column or use the facets to filter. Open an order to see its full + master-detail view: the line items with quantities, unit prices and line totals, the delivery with + its address, and the people and companies involved.

+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-orders> a ldh:Object ; @@ -38,17 +48,19 @@ schema:identifier ?orderID ; schema:orderDate ?orderDate ; schema:customer ?customer ; - schema:broker ?employee + schema:broker ?employee ; + schema:orderStatus ?status ; + schema:totalPrice ?total } } - ORDER BY ?orderID""" . + ORDER BY DESC(?orderDate)""" . - # Intro XHTML - <#orders-intro> a ldh:XHTML ; + # Analytics intro + <#analytics-intro> a ldh:XHTML ; rdf:value """

Order analytics

-

Track order volume, geographic distribution, and temporal trends. Understanding order patterns - helps optimize inventory, predict demand, and identify growth opportunities across different markets.

+

Beyond the individual orders, the Knowledge Graph answers aggregate questions with live SPARQL + queries. How has order volume developed over time?

"""^^rdf:XMLLiteral . # Orders over time chart (wrapped in ldh:Object per ContentMode contract) @@ -81,8 +93,8 @@ ORDER BY ?month""" . <#geographic-intro> a ldh:XHTML ; rdf:value """

Geographic distribution

-

Orders span multiple countries and regions. Analyzing geographic patterns reveals market penetration - and helps identify expansion opportunities or regional challenges.

+

And where do the orders go? Each order's delivery address places it on the map — the chart below + counts orders per destination country, revealing where Northwind's markets are strongest.

"""^^rdf:XMLLiteral . # Orders by country chart (wrapped in ldh:Object per ContentMode contract) @@ -111,52 +123,3 @@ WHERE { GROUP BY ?country ORDER BY DESC(?orderCount) LIMIT 10""" . - - # Chart block (wrapped in ldh:Object per ContentMode contract) - <#sales-by-region-per-year-block> a ldh:Object ; - rdf:value <#sales-by-region-per-year> . - - <#sales-by-region-per-year> a ldh:ResultSetChart ; - dct:title "Sales by region per year" ; - spin:query <#sales-by-regions-by-year-query> ; - ldh:chartType ; - ldh:categoryVarName "year" ; - ldh:seriesVarName "regionName" ; - ldh:seriesVarName "totalSales" . - - # Order trends summary - <#order-trends-summary> a ldh:XHTML ; - rdf:value """
-

Regional performance

-

The table below shows sales performance broken down by region and year, enabling comparison - of regional growth patterns and seasonal variations.

-
"""^^rdf:XMLLiteral . - - # Chart SELECT query - <#sales-by-regions-by-year-query> a sp:Select ; - dct:title "Sales by region per year" ; - sp:text """PREFIX schema: -PREFIX xsd: - -SELECT DISTINCT ?year ?regionName (SUM(?sale) AS ?totalSales) -WHERE - { GRAPH ?orderGraph - { ?order schema:orderDate ?orderDate ; - schema:broker ?employee ; - schema:orderedItem ?orderItem - BIND(year(xsd:dateTime(?orderDate)) AS ?year) - ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price - BIND(( ?quantity * ?price ) AS ?sale) - } - GRAPH ?employeeGraph - { ?employee schema:areaServed ?territory } - GRAPH ?territoryGraph - { ?territory schema:containedInPlace ?region } - GRAPH ?regionGraph - { ?region schema:name ?regionName } - } -GROUP BY ?year ?regionName -ORDER BY DESC(?year) ?regionName -LIMIT 100""" . diff --git a/demo/northwind-traders/orders/order_details.rq b/demo/northwind-traders/orders/order_details.rq index 30771e9..814b1c3 100644 --- a/demo/northwind-traders/orders/order_details.rq +++ b/demo/northwind-traders/orders/order_details.rq @@ -7,13 +7,16 @@ CONSTRUCT { GRAPH ?graph { + ?graph foaf:topic ?orderItem . + ?order schema:orderedItem ?orderItem . ?orderItem a schema:OrderItem ; schema:orderedItem ?product ; schema:orderQuantity ?quantity ; schema:discount ?discount ; - schema:price ?price . + schema:price ?price ; + schema:totalPrice ?lineTotal . } } WHERE @@ -31,4 +34,5 @@ WHERE BIND (STRDT(?unitPrice, xsd:float) AS ?price) BIND (STRDT(?quantityString, xsd:integer) AS ?quantity) BIND (STRDT(?discountString, xsd:float) AS ?discount) + BIND (STRDT(str(round(?quantity * ?price * (1 - ?discount) * 100) / 100), xsd:float) AS ?lineTotal) } diff --git a/demo/northwind-traders/orders/orders.rq b/demo/northwind-traders/orders/orders.rq index 98fbab5..b53f483 100644 --- a/demo/northwind-traders/orders/orders.rq +++ b/demo/northwind-traders/orders/orders.rq @@ -17,12 +17,14 @@ CONSTRUCT schema:customer ?customer ; schema:broker ?employee ; schema:orderDate ?orderDate ; + schema:orderStatus ?status ; schema:orderDelivery ?orderDelivery . ?orderDelivery a schema:ParcelDelivery ; foaf:page ?graph ; schema:expectedArrivalUntil ?requiredDate ; - # ?shippedDate ; + schema:availableFrom ?shippedDate ; + schema:price ?freight ; schema:deliveryAddress ?deliveryAddress ; schema:provider ?shipper . @@ -32,7 +34,6 @@ CONSTRUCT schema:addressLocality ?shipCity ; schema:postalCode ?shipPostalCode ; schema:streetAddress ?shipAddress ; - schema:location ?deliveryLocation ; schema:addressRegion ?shipRegion . } } @@ -70,4 +71,7 @@ WHERE BIND(strdt(?orderDateString, xsd:date) AS ?orderDate) BIND(strdt(?requiredDateString, xsd:date) AS ?requiredDate) BIND(strdt(?freightString, xsd:float) AS ?freight) + BIND(IF(BOUND(?shippedDate), + IF(?shippedDate > ?requiredDate, schema:OrderProblem, schema:OrderDelivered), + schema:OrderProcessing) AS ?status) } diff --git a/demo/northwind-traders/root.ttl b/demo/northwind-traders/root.ttl index 4bb26a9..d26564d 100644 --- a/demo/northwind-traders/root.ttl +++ b/demo/northwind-traders/root.ttl @@ -14,13 +14,14 @@ dct:description "Knowledge Graph representation of the Northwind Traders sample database" ; rdf:_1 <#page-header> ; rdf:_2 <#overview-intro> ; - rdf:_3 <#sales-trend-block> ; - rdf:_4 <#revenue-by-country-block> ; - rdf:_5 <#top-selling-products> ; - rdf:_6 <#top-manager-header> ; - rdf:_7 <#top-manager> ; - rdf:_8 <#navigation-prompt> ; - rdf:_9 <#select-children> . + rdf:_3 <#kpi-block> ; + rdf:_4 <#sales-trend-block> ; + rdf:_5 <#revenue-by-country-block> ; + rdf:_6 <#top-selling-products> ; + rdf:_7 <#top-manager-header> ; + rdf:_8 <#top-manager> ; + rdf:_9 <#navigation-prompt> ; + rdf:_10 <#select-children> . <#page-header> a ldh:XHTML ; rdf:value """
@@ -37,9 +38,31 @@ <#overview-intro> a ldh:XHTML ; rdf:value """

Executive dashboard

-

Explore key business metrics including sales trends, geographic distribution, and product performance. All visualizations are generated from live SPARQL queries over the RDF Knowledge Graph.

+

How is the business doing? The numbers below sum up all 830 orders at a glance, followed by sales trends, geographic distribution, and product performance. Everything on this page is generated by live SPARQL queries over the RDF Knowledge Graph.

"""^^rdf:XMLLiteral . +<#kpi-block> a ldh:Object ; + rdf:value <#key-metrics> . + +<#key-metrics> a ldh:ResultSetChart ; + dct:title "Key metrics" ; + spin:query <#key-metrics-query> ; + ldh:chartType ; + ldh:categoryVarName "orders" ; + ldh:seriesVarName "revenue" . + +<#key-metrics-query> a sp:Select ; + dct:title "Key metrics" ; + sp:text """PREFIX schema: + +SELECT (COUNT(?order) AS ?orders) (SUM(?total) AS ?revenue) (ROUND(SUM(?total) / COUNT(?order)) AS ?avgOrderValue) +WHERE { + GRAPH ?orderGraph { + ?order a schema:Order ; + schema:totalPrice ?total . + } +}""" . + <#sales-trend-block> a ldh:Object ; rdf:value <#sales-trend> . @@ -114,7 +137,7 @@ LIMIT 10""" . <#navigation-prompt> a ldh:XHTML ; rdf:value """

Explore more

-

Dive deeper into detailed analytics by exploring the containers below. Each section provides comprehensive insights into products, orders, customers, employees, and more.

+

Dive deeper by exploring the sections below. Start with Orders — the order book lists every order with its status and total, and each order opens into a master-detail view of its line items, delivery and the people involved. Products, customers, employees and the other sections offer their own analytics and cross-links.

"""^^rdf:XMLLiteral . <#select-children> a ldh:Object ; From a2eeaa72b3a782a0146a90c83e435a8f33ef6749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 29 Aug 2026 16:19:42 +0200 Subject: [PATCH 3/8] Present Northwind as a business application rather than a data browser Page structure and copy: - Drop the frontpage block advertising LinkedDataHub's features and the low-code build story; an app does not explain its own toolchain - Lead the dashboard with the monthly sales trend instead of a two-value table stretched over the 400px chart canvas - Record lists come before analytics in each container, so a section opens on its data; narrative blocks run between sections throughout - Give the four reference containers (suppliers, shippers, regions, territories) the intros the others already had - Drop the revenue-by-category chart duplicated in products.ttl; it belongs to categories.ttl Labelling: - dct:title on every ldh:Object block. Without it the block heading renders the fragment identifier, so pages showed "kpi-block", "select-suppliers" - Northwind's own vocabulary in the ontology: Sales rep (not Broker), Reports to (not Sponsor), Territory, Region, Company name, Required by - Use .ldh-section for section headers; page-header/lead are Bootstrap 2 leftovers with no rules in the current design system Correctness: - Revenue charts are now net of schema:discount. They summed quantity x price and overstated by 6.7% (1,297,141 gross against 1,215,813 net), so no chart reconciled with the order totals - No hardcoded counts in prose. "all 830 orders" was also wrong: 19 rows are dropped at import because shipPostalCode is empty and the mapping requires it - Exclude the final, incomplete month from the trend. The data stops on 1998-05-06, which rendered as a collapse rather than a truncated month; the cutoff is derived from MAX(?orderDate), not pinned to a date - Order-scoped schema:totalPrice is no longer required by any query, following its removal from the import mapping Charts: - Add revenue by carrier to Orders, the first chart here with multiple series. Each ldh:seriesVarName names its own measure column, so the query pivots with conditional aggregation; the removed sales-by-region chart passed a label column plus a value column, which is why it never rendered Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg --- demo/northwind-traders/admin/model/ns.ttl | 38 ++++----- demo/northwind-traders/categories.ttl | 43 +++++++--- demo/northwind-traders/customers.ttl | 41 ++++++---- demo/northwind-traders/employees.ttl | 30 +++++-- demo/northwind-traders/orders.ttl | 99 +++++++++++++++++++---- demo/northwind-traders/products.ttl | 88 +++++++------------- demo/northwind-traders/regions.ttl | 14 +++- demo/northwind-traders/root.ttl | 95 ++++++++++------------ demo/northwind-traders/shippers.ttl | 13 ++- demo/northwind-traders/suppliers.ttl | 14 +++- demo/northwind-traders/territories.ttl | 14 +++- 11 files changed, 303 insertions(+), 186 deletions(-) diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index 081ac43..100fef6 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -21,7 +21,7 @@ schema:Order a owl:Class ; rdfs:isDefinedBy : . schema:City a owl:Class ; - rdfs:label "City" ; + rdfs:label "Territory" ; rdfs:isDefinedBy : . schema:PostalAddress a owl:Class ; @@ -29,11 +29,11 @@ schema:PostalAddress a owl:Class ; rdfs:isDefinedBy : . schema:ProductGroup a owl:Class ; - rdfs:label "Product group" ; + rdfs:label "Category" ; rdfs:isDefinedBy : . schema:Corporation a owl:Class ; - rdfs:label "Corporation" ; + rdfs:label "Company" ; rdfs:isDefinedBy : . schema:ContactPoint a owl:Class ; @@ -41,7 +41,7 @@ schema:ContactPoint a owl:Class ; rdfs:isDefinedBy : . schema:ParcelDelivery a owl:Class ; - rdfs:label "Parcel delivery" ; + rdfs:label "Delivery" ; rdfs:isDefinedBy : . schema:OrderItem a owl:Class ; @@ -53,19 +53,19 @@ schema:Product a owl:Class ; rdfs:isDefinedBy : . schema:Place a owl:Class ; - rdfs:label "Place" ; + rdfs:label "Region" ; rdfs:isDefinedBy : . # properties schema:broker a owl:ObjectProperty ; - rdfs:label "Broker"; + rdfs:label "Sales rep"; rdfs:domain schema:Order ; rdfs:range schema:Person ; rdfs:isDefinedBy : . schema:areaServed a owl:ObjectProperty ; - rdfs:label "Area served"; + rdfs:label "Territory"; rdfs:domain schema:Person ; rdfs:range schema:City ; rdfs:isDefinedBy : . @@ -95,7 +95,7 @@ schema:customer a owl:ObjectProperty ; rdfs:isDefinedBy : . schema:orderDelivery a owl:ObjectProperty ; - rdfs:label "Order delivery" ; + rdfs:label "Delivery" ; rdfs:domain schema:Order ; rdfs:range schema:ParcelDelivery ; rdfs:isDefinedBy : . @@ -118,13 +118,13 @@ schema:category a owl:ObjectProperty ; rdfs:isDefinedBy : . schema:containedInPlace a owl:ObjectProperty ; - rdfs:label "Contained in place" ; + rdfs:label "Region" ; rdfs:domain schema:City ; rdfs:range schema:Place ; rdfs:isDefinedBy : . schema:sponsor a owl:ObjectProperty ; - rdfs:label "Sponsor" ; + rdfs:label "Reports to" ; rdfs:domain schema:Person ; rdfs:range schema:Person ; rdfs:isDefinedBy : . @@ -153,7 +153,7 @@ schema:description a owl:DatatypeProperty ; rdfs:isDefinedBy : . schema:legalName a owl:DatatypeProperty ; - rdfs:label "Legal name" ; + rdfs:label "Company name" ; rdfs:domain schema:Corporation ; rdfs:isDefinedBy : . @@ -216,7 +216,7 @@ schema:birthDate a owl:DatatypeProperty ; rdfs:isDefinedBy : . schema:orderQuantity a owl:DatatypeProperty ; - rdfs:label "Order quantity" ; + rdfs:label "Quantity" ; rdfs:domain schema:OrderItem ; rdfs:isDefinedBy : . @@ -244,7 +244,7 @@ schema:orderDate a owl:DatatypeProperty ; rdfs:isDefinedBy : . schema:expectedArrivalUntil a owl:DatatypeProperty ; - rdfs:label "Expected arrival until" ; + rdfs:label "Required by" ; rdfs:domain schema:ParcelDelivery ; rdfs:isDefinedBy : . @@ -293,8 +293,7 @@ WHERE { GRAPH ?graph { ?order schema:broker $about ; schema:orderDate ?orderDate ; - schema:orderStatus ?status ; - schema:totalPrice ?total + schema:orderStatus ?status } } ORDER BY DESC(?orderDate) @@ -324,8 +323,7 @@ WHERE { GRAPH ?graph { ?order schema:customer $about ; schema:orderDate ?orderDate ; - schema:orderStatus ?status ; - schema:totalPrice ?total + schema:orderStatus ?status } } ORDER BY DESC(?orderDate) @@ -384,8 +382,7 @@ WHERE { GRAPH ?graph { ?order schema:orderDelivery ?delivery ; schema:orderDate ?orderDate ; - schema:orderStatus ?status ; - schema:totalPrice ?total . + schema:orderStatus ?status . ?delivery schema:provider $about } } @@ -472,8 +469,7 @@ WHERE { GRAPH ?graph { ?order schema:orderedItem ?orderItem ; schema:orderDate ?orderDate ; - schema:orderStatus ?status ; - schema:totalPrice ?total . + schema:orderStatus ?status . ?orderItem schema:orderedItem $about } } diff --git a/demo/northwind-traders/categories.ttl b/demo/northwind-traders/categories.ttl index 04e071d..eab98fa 100644 --- a/demo/northwind-traders/categories.ttl +++ b/demo/northwind-traders/categories.ttl @@ -10,21 +10,35 @@ <> a dh:Container ; dct:title "Categories" ; rdf:_1 <#category-intro> ; - rdf:_2 <#category-revenue-block> ; - rdf:_3 <#category-distribution> ; - rdf:_4 <#products-per-category-block> ; - rdf:_5 <#select-categories> . + rdf:_2 <#select-categories> ; + rdf:_3 <#revenue-intro> ; + rdf:_4 <#category-revenue-block> ; + rdf:_5 <#category-distribution> ; + rdf:_6 <#products-per-category-block> . # Intro XHTML <#category-intro> a ldh:XHTML ; rdf:value """
-

Category analysis

-

Product categories represent different market segments with distinct characteristics and performance. - Analyzing category-level metrics helps identify portfolio strengths, diversification opportunities, and inventory priorities.

+
+

Product categories

+

The segments the catalogue is organised into — beverages, condiments, produce and the rest. + Open one to see the products it holds.

+
+
"""^^rdf:XMLLiteral . + + # Revenue intro + <#revenue-intro> a ldh:XHTML ; + rdf:value """
+
+

Where the money is

+

Each category is a market segment with its own performance. Revenue first, then the spread of + products behind it.

+
"""^^rdf:XMLLiteral . # Category revenue chart (wrapped in ldh:Object per ContentMode contract) <#category-revenue-block> a ldh:Object ; + dct:title "Revenue by category" ; rdf:value <#category-revenue> . <#category-revenue> a ldh:ResultSetChart ; @@ -44,8 +58,9 @@ WHERE { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + schema:price ?price ; + schema:discount ?discount . + BIND(?quantity * ?price * (1 - ?discount) AS ?sale) } GRAPH ?productGraph { ?product schema:category ?category . @@ -60,13 +75,16 @@ ORDER BY DESC(?revenue)""" . # Category distribution intro <#category-distribution> a ldh:XHTML ; rdf:value """
-

Product distribution

-

Understanding how products are distributed across categories reveals portfolio balance and potential gaps. - Categories with more products may indicate core competencies or simply broader product lines.

+
+

Portfolio balance

+

Revenue and range don't always line up. A category can earn well on few lines, or spread wide + and earn little — the gap between the two charts is where the portfolio questions are.

+
"""^^rdf:XMLLiteral . # Products per category chart (wrapped in ldh:Object per ContentMode contract) <#products-per-category-block> a ldh:Object ; + dct:title "Products per category" ; rdf:value <#products-per-category> . <#products-per-category> a ldh:ResultSetChart ; @@ -95,6 +113,7 @@ ORDER BY DESC(?productCount)""" . # Object block (references the view) <#select-categories> a ldh:Object ; + dct:title "All categories" ; rdf:value <#select-categories-view> . # View block (references the query, uses GridMode) diff --git a/demo/northwind-traders/customers.ttl b/demo/northwind-traders/customers.ttl index 27e421f..b88eff3 100644 --- a/demo/northwind-traders/customers.ttl +++ b/demo/northwind-traders/customers.ttl @@ -10,22 +10,25 @@ <> a dh:Container ; dct:title "Customers" ; rdf:_1 <#customers-intro> ; - rdf:_2 <#top-customers-block> ; - rdf:_3 <#geographic-distribution> ; - rdf:_4 <#customers-by-country-block> ; - rdf:_5 <#customer-insights> ; - rdf:_6 <#select-customers> . + rdf:_2 <#select-customers> ; + rdf:_3 <#customer-insights> ; + rdf:_4 <#top-customers-block> ; + rdf:_5 <#geographic-distribution> ; + rdf:_6 <#customers-by-country-block> . # Intro XHTML <#customers-intro> a ldh:XHTML ; rdf:value """
-

Customer insights

-

Understand customer value, geographic distribution, and purchasing patterns. Identifying top customers - and market concentration helps prioritize account management and target growth strategies.

+
+

The account book

+

Every company Northwind sells to, with its contact details and address. Open an account to see + its full order history.

+
"""^^rdf:XMLLiteral . # Top customers chart (wrapped in ldh:Object per ContentMode contract) <#top-customers-block> a ldh:Object ; + dct:title "Top 10 customers by revenue" ; rdf:value <#top-customers> . <#top-customers> a ldh:ResultSetChart ; @@ -45,8 +48,9 @@ WHERE { ?order schema:customer ?customer ; schema:orderedItem ?orderItem . ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + schema:price ?price ; + schema:discount ?discount . + BIND(?quantity * ?price * (1 - ?discount) AS ?sale) } GRAPH ?customerGraph { ?customer schema:legalName ?companyName . @@ -59,13 +63,16 @@ LIMIT 10""" . # Geographic distribution intro <#geographic-distribution> a ldh:XHTML ; rdf:value """
-

Geographic distribution

-

Our customer base spans multiple countries, with varying levels of market penetration. - Understanding geographic concentration helps guide expansion efforts and resource allocation.

+
+

Geographic distribution

+

The customer base spans many countries at very different depths. Where the accounts cluster is + where the account managers should be.

+
"""^^rdf:XMLLiteral . # Customers by country chart (wrapped in ldh:Object per ContentMode contract) <#customers-by-country-block> a ldh:Object ; + dct:title "Customers by country" ; rdf:value <#customers-by-country> . <#customers-by-country> a ldh:ResultSetChart ; @@ -94,13 +101,15 @@ ORDER BY DESC(?customerCount)""" . # Customer insights summary <#customer-insights> a ldh:XHTML ; rdf:value """
-

Customer portfolio

-

Browse individual customer records below to view detailed order history, contact information, - and relationship details. Each customer profile provides insights into purchasing behavior and account status.

+
+

Account value

+

Not every account is worth the same. Revenue concentrates in a handful of them.

+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-customers> a ldh:Object ; + dct:title "All customers" ; rdf:value <#select-customers-view> . # View block (references the query, uses TableMode) diff --git a/demo/northwind-traders/employees.ttl b/demo/northwind-traders/employees.ttl index ec672cc..308e9ce 100644 --- a/demo/northwind-traders/employees.ttl +++ b/demo/northwind-traders/employees.ttl @@ -10,19 +10,33 @@ <> a dh:Container ; dct:title "Employees" ; rdf:_1 <#employee-intro> ; - rdf:_2 <#sales-by-employee-block> ; - rdf:_3 <#select-employees> . + rdf:_2 <#select-employees> ; + rdf:_3 <#performance-intro> ; + rdf:_4 <#sales-by-employee-block> . # Intro XHTML <#employee-intro> a ldh:XHTML ; rdf:value """
-

Employee performance

-

Track employee sales performance and order processing metrics. Understanding individual - contributions helps recognize top performers, identify coaching opportunities, and optimize territory assignments.

+
+

The team

+

Everyone on the Northwind payroll, who they report to and the territories they cover. Open a + record to see the orders they booked.

+
+
"""^^rdf:XMLLiteral . + + # Performance intro + <#performance-intro> a ldh:XHTML ; + rdf:value """
+
+

Sales performance

+

Revenue booked per sales rep — the basis for recognising top performers and rebalancing + territory assignments.

+
"""^^rdf:XMLLiteral . # Sales by employee chart (wrapped in ldh:Object per ContentMode contract) <#sales-by-employee-block> a ldh:Object ; + dct:title "Sales by employee" ; rdf:value <#sales-by-employee> . <#sales-by-employee> a ldh:ResultSetChart ; @@ -42,8 +56,9 @@ WHERE { ?order schema:broker ?employee ; schema:orderedItem ?orderItem . ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + schema:price ?price ; + schema:discount ?discount . + BIND(?quantity * ?price * (1 - ?discount) AS ?sale) } GRAPH ?employeeGraph { ?employee schema:givenName ?givenName ; @@ -56,6 +71,7 @@ ORDER BY DESC(?totalSales)""" . # Object block (references the view) <#select-employees> a ldh:Object ; + dct:title "All employees" ; rdf:value <#select-employees-view> . # View block (references the query, uses GridMode) diff --git a/demo/northwind-traders/orders.ttl b/demo/northwind-traders/orders.ttl index 1f6d189..3b111d5 100644 --- a/demo/northwind-traders/orders.ttl +++ b/demo/northwind-traders/orders.ttl @@ -14,21 +14,26 @@ rdf:_3 <#analytics-intro> ; rdf:_4 <#orders-over-time-block> ; rdf:_5 <#geographic-intro> ; - rdf:_6 <#orders-by-country-block> . + rdf:_6 <#orders-by-country-block> ; + rdf:_7 <#carrier-intro> ; + rdf:_8 <#revenue-by-carrier-block> . # Intro XHTML <#orders-intro> a ldh:XHTML ; rdf:value """
-

The order book

-

Every order links a customer, the employee who brokered it, the shipper that delivered it, and the - products it contains. The gallery below lists all 830 orders, newest first, with their status and - total value — sort any column or use the facets to filter. Open an order to see its full - master-detail view: the line items with quantities, unit prices and line totals, the delivery with - its address, and the people and companies involved.

+
+

The order book

+

Every order links a customer, the sales rep who booked it, the shipper that delivered it and the + products it contains. Orders are listed newest first with their status — sort any column or + filter with the facets. Open one for its full master–detail view: line items with quantities, + unit prices and line totals, the delivery and its address, and the people and companies + involved.

+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-orders> a ldh:Object ; + dct:title "All orders" ; rdf:value <#select-orders-view> . # View block (references the query, uses TableMode) @@ -49,8 +54,7 @@ schema:orderDate ?orderDate ; schema:customer ?customer ; schema:broker ?employee ; - schema:orderStatus ?status ; - schema:totalPrice ?total + schema:orderStatus ?status } } ORDER BY DESC(?orderDate)""" . @@ -58,13 +62,15 @@ # Analytics intro <#analytics-intro> a ldh:XHTML ; rdf:value """
-

Order analytics

-

Beyond the individual orders, the Knowledge Graph answers aggregate questions with live SPARQL - queries. How has order volume developed over time?

+
+

Order analytics

+

Beyond the individual records, how has order volume developed over time?

+
"""^^rdf:XMLLiteral . # Orders over time chart (wrapped in ldh:Object per ContentMode contract) <#orders-over-time-block> a ldh:Object ; + dct:title "Orders per month" ; rdf:value <#orders-over-time> . <#orders-over-time> a ldh:ResultSetChart ; @@ -92,13 +98,16 @@ ORDER BY ?month""" . # Geographic intro <#geographic-intro> a ldh:XHTML ; rdf:value """
-

Geographic distribution

-

And where do the orders go? Each order's delivery address places it on the map — the chart below - counts orders per destination country, revealing where Northwind's markets are strongest.

+
+

Geographic distribution

+

And where do the orders go? Each order's delivery address places it in a destination country, + showing where Northwind's markets are strongest.

+
"""^^rdf:XMLLiteral . # Orders by country chart (wrapped in ldh:Object per ContentMode contract) <#orders-by-country-block> a ldh:Object ; + dct:title "Orders by country" ; rdf:value <#orders-by-country> . <#orders-by-country> a ldh:ResultSetChart ; @@ -123,3 +132,63 @@ WHERE { GROUP BY ?country ORDER BY DESC(?orderCount) LIMIT 10""" . + + # Carrier intro + <#carrier-intro> a ldh:XHTML ; + rdf:value """
+
+

Who carries the freight

+

Three carriers deliver every order. Their monthly revenue shows how the + shipping mix has shifted.

+
+
"""^^rdf:XMLLiteral . + + # Revenue by carrier - the one multi-series chart in the app. Each ldh:seriesVarName + # names its own measure column (wide format); the query pivots with conditional + # aggregation. Three carriers is a small fixed set, so naming them here is tolerable - + # a fourth would silently not appear. + <#revenue-by-carrier-block> a ldh:Object ; + dct:title "Revenue by carrier" ; + rdf:value <#revenue-by-carrier> . + + <#revenue-by-carrier> a ldh:ResultSetChart ; + dct:title "Revenue by carrier" ; + spin:query <#revenue-by-carrier-query> ; + ldh:chartType ; + ldh:categoryVarName "month" ; + ldh:seriesVarName "speedyExpress" ; + ldh:seriesVarName "unitedPackage" ; + ldh:seriesVarName "federalShipping" . + + <#revenue-by-carrier-query> a sp:Select ; + dct:title "Revenue by carrier" ; + sp:text """PREFIX schema: + +SELECT ?month + (ROUND(SUM(IF(?carrier = "Speedy Express", ?lineTotal, 0))) AS ?speedyExpress) + (ROUND(SUM(IF(?carrier = "United Package", ?lineTotal, 0))) AS ?unitedPackage) + (ROUND(SUM(IF(?carrier = "Federal Shipping", ?lineTotal, 0))) AS ?federalShipping) +WHERE { + GRAPH ?orderGraph { + ?order a schema:Order ; + schema:orderDate ?orderDate ; + schema:orderDelivery ?delivery ; + schema:orderedItem ?orderItem . + ?orderItem schema:totalPrice ?lineTotal . + ?delivery schema:provider ?shipper . + } + GRAPH ?shipperGraph { + ?shipper schema:legalName ?carrier . + } + BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) + # Drop the final, incomplete month - derived from the data rather than pinned to a date. + { + SELECT (SUBSTR(STR(MAX(?anyDate)), 1, 7) AS ?partialMonth) + WHERE { + GRAPH ?g { ?anyOrder a schema:Order ; schema:orderDate ?anyDate } + } + } + FILTER (?month < ?partialMonth) +} +GROUP BY ?month +ORDER BY ?month""" . diff --git a/demo/northwind-traders/products.ttl b/demo/northwind-traders/products.ttl index 84ba577..f5dd90d 100644 --- a/demo/northwind-traders/products.ttl +++ b/demo/northwind-traders/products.ttl @@ -10,24 +10,35 @@ <> a dh:Container ; dct:title "Products" ; rdf:_1 <#products-intro> ; - rdf:_2 <#top-selling-products-block> ; - rdf:_3 <#category-analysis-intro> ; - rdf:_4 <#revenue-by-category-block> ; - rdf:_5 <#products-by-supplier-block> ; - rdf:_6 <#supplier-context> ; - rdf:_7 <#select-products> . + rdf:_2 <#select-products> ; + rdf:_3 <#performance-intro> ; + rdf:_4 <#top-selling-products-block> ; + rdf:_5 <#supplier-context> ; + rdf:_6 <#products-by-supplier-block> . # Intro XHTML <#products-intro> a ldh:XHTML ; rdf:value """
-

Product analytics

-

Analyze product performance, category distribution, and supplier relationships. - The Northwind catalog includes products across multiple categories from various suppliers, - each with unique pricing and inventory characteristics.

+
+

The catalogue

+

Every product Northwind sells, with its category, supplier and unit price. Open one to see the + orders it appears in and the supplier behind it.

+
+
"""^^rdf:XMLLiteral . + + # Performance intro + <#performance-intro> a ldh:XHTML ; + rdf:value """
+
+

Product performance

+

Which lines actually carry the revenue? Category-level breakdowns live under + Categories.

+
"""^^rdf:XMLLiteral . # Chart block (wrapped in ldh:Object per ContentMode contract) <#top-selling-products-block> a ldh:Object ; + dct:title "Top selling products" ; rdf:value <#top-selling-products> . <#top-selling-products> a ldh:ResultSetChart ; @@ -48,8 +59,9 @@ WHERE { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; schema:orderQuantity ?quantity ; - schema:price ?price - BIND (?quantity * ?price AS ?sale) + schema:price ?price ; + schema:discount ?discount + BIND (?quantity * ?price * (1 - ?discount) AS ?sale) } GRAPH ?productGraph { ?product schema:name ?productName } @@ -58,50 +70,9 @@ GROUP BY ?product ?productName ORDER BY DESC(?totalSales) LIMIT 5""" . - # Category analysis intro - <#category-analysis-intro> a ldh:XHTML ; - rdf:value """
-

Category performance

-

Understanding how products and revenue are distributed across categories helps identify - market strengths and opportunities. The following charts break down revenue and product count by category.

-
"""^^rdf:XMLLiteral . - - # Revenue by category chart (wrapped in ldh:Object per ContentMode contract) - <#revenue-by-category-block> a ldh:Object ; - rdf:value <#revenue-by-category> . - - <#revenue-by-category> a ldh:ResultSetChart ; - dct:title "Revenue by category" ; - spin:query <#category-revenue-query> ; - ldh:chartType ; - ldh:categoryVarName "categoryName" ; - ldh:seriesVarName "revenue" . - - <#category-revenue-query> a sp:Select ; - dct:title "Category revenue" ; - sp:text """PREFIX schema: - -SELECT ?categoryName (SUM(?sale) AS ?revenue) -WHERE { - GRAPH ?orderGraph { - ?order schema:orderedItem ?orderItem . - ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) - } - GRAPH ?productGraph { - ?product schema:category ?category . - } - GRAPH ?categoryGraph { - ?category schema:name ?categoryName . - } -} -GROUP BY ?category ?categoryName -ORDER BY DESC(?revenue)""" . - # Products by supplier chart (wrapped in ldh:Object per ContentMode contract) <#products-by-supplier-block> a ldh:Object ; + dct:title "Products by supplier" ; rdf:value <#products-by-supplier> . <#products-by-supplier> a ldh:ResultSetChart ; @@ -132,13 +103,16 @@ LIMIT 10""" . # Supplier context <#supplier-context> a ldh:XHTML ; rdf:value """
-

Supplier relationships

-

Northwind Traders works with suppliers worldwide to source products. The distribution of products - across suppliers shows which partnerships are most productive and may indicate supply chain dependencies.

+
+

Supplier relationships

+

Northwind sources from suppliers worldwide. How the catalogue spreads across them shows which + partnerships carry the range — and where the supply chain concentrates.

+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-products> a ldh:Object ; + dct:title "All products" ; rdf:value <#select-products-view> . # View block (references the query, uses TableMode) diff --git a/demo/northwind-traders/regions.ttl b/demo/northwind-traders/regions.ttl index f9f8000..250531e 100644 --- a/demo/northwind-traders/regions.ttl +++ b/demo/northwind-traders/regions.ttl @@ -8,10 +8,22 @@ # Main container document <> a dh:Container ; dct:title "Regions" ; - rdf:_1 <#select-regions> . + rdf:_1 <#regions-intro> ; + rdf:_2 <#select-regions> . + + # Intro XHTML + <#regions-intro> a ldh:XHTML ; + rdf:value """
+
+

Sales regions

+

The top level of the sales geography. Each region contains the + territories that reps are assigned to.

+
+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-regions> a ldh:Object ; + dct:title "All regions" ; rdf:value <#select-regions-view> . # View block (references the query, no mode specified - uses default) diff --git a/demo/northwind-traders/root.ttl b/demo/northwind-traders/root.ttl index d26564d..877845a 100644 --- a/demo/northwind-traders/root.ttl +++ b/demo/northwind-traders/root.ttl @@ -12,58 +12,25 @@ <> a def:Root ; dct:title "Northwind Traders" ; dct:description "Knowledge Graph representation of the Northwind Traders sample database" ; - rdf:_1 <#page-header> ; - rdf:_2 <#overview-intro> ; - rdf:_3 <#kpi-block> ; - rdf:_4 <#sales-trend-block> ; - rdf:_5 <#revenue-by-country-block> ; - rdf:_6 <#top-selling-products> ; - rdf:_7 <#top-manager-header> ; - rdf:_8 <#top-manager> ; - rdf:_9 <#navigation-prompt> ; - rdf:_10 <#select-children> . - -<#page-header> a ldh:XHTML ; - rdf:value """
- -

Features include CSV data imports, SPARQL-based charts, faceted search and related results (parallax navigation) as well as - rich documents composed of structured content backed by RDF Knowledge Graph data.

-

This application was created on LinkedDataHub using the low code paradigm: no programming was required, only scripts that - invoke LinkedDataHub's CLI commands.

-
"""^^rdf:XMLLiteral . + rdf:_1 <#overview-intro> ; + rdf:_2 <#sales-trend-block> ; + rdf:_3 <#revenue-by-country-block> ; + rdf:_4 <#top-selling-products> ; + rdf:_5 <#top-manager-header> ; + rdf:_6 <#top-manager> ; + rdf:_7 <#navigation-prompt> ; + rdf:_8 <#select-children> . <#overview-intro> a ldh:XHTML ; rdf:value """
-

Executive dashboard

-

How is the business doing? The numbers below sum up all 830 orders at a glance, followed by sales trends, geographic distribution, and product performance. Everything on this page is generated by live SPARQL queries over the RDF Knowledge Graph.

+
+

Executive dashboard

+

Sales performance at a glance — revenue trends, market distribution and product mix.

+
"""^^rdf:XMLLiteral . -<#kpi-block> a ldh:Object ; - rdf:value <#key-metrics> . - -<#key-metrics> a ldh:ResultSetChart ; - dct:title "Key metrics" ; - spin:query <#key-metrics-query> ; - ldh:chartType ; - ldh:categoryVarName "orders" ; - ldh:seriesVarName "revenue" . - -<#key-metrics-query> a sp:Select ; - dct:title "Key metrics" ; - sp:text """PREFIX schema: - -SELECT (COUNT(?order) AS ?orders) (SUM(?total) AS ?revenue) (ROUND(SUM(?total) / COUNT(?order)) AS ?avgOrderValue) -WHERE { - GRAPH ?orderGraph { - ?order a schema:Order ; - schema:totalPrice ?total . - } -}""" . - <#sales-trend-block> a ldh:Object ; + dct:title "Monthly sales trend" ; rdf:value <#sales-trend> . <#sales-trend> a ldh:ResultSetChart ; @@ -84,15 +51,27 @@ WHERE { schema:orderDate ?orderDate ; schema:orderedItem ?orderItem . ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + schema:price ?price ; + schema:discount ?discount . + BIND(?quantity * ?price * (1 - ?discount) AS ?sale) BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) } + # The order book stops part-way through its final month, which would render as a + # cliff rather than a trend. Drop that month — derived from the data, not pinned + # to a date, so it stays correct if the dataset grows. + { + SELECT (SUBSTR(STR(MAX(?anyDate)), 1, 7) AS ?partialMonth) + WHERE { + GRAPH ?g { ?anyOrder a schema:Order ; schema:orderDate ?anyDate } + } + } + FILTER (?month < ?partialMonth) } GROUP BY ?month ORDER BY ?month""" . <#revenue-by-country-block> a ldh:Object ; + dct:title "Revenue by country" ; rdf:value <#revenue-by-country> . <#revenue-by-country> a ldh:ResultSetChart ; @@ -112,10 +91,11 @@ WHERE { ?order schema:orderedItem ?orderItem ; schema:orderDelivery ?delivery . ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . + schema:price ?price ; + schema:discount ?discount . ?delivery schema:deliveryAddress ?address . ?address schema:addressCountry ?country . - BIND(?quantity * ?price AS ?sale) + BIND(?quantity * ?price * (1 - ?discount) AS ?sale) } } GROUP BY ?country @@ -123,22 +103,29 @@ ORDER BY DESC(?revenue) LIMIT 10""" . <#top-selling-products> a ldh:Object ; + dct:title "Top selling products" ; rdf:value . <#top-manager-header> a ldh:XHTML ; rdf:value """
-

Top manager

-

Meet the top manager of Northwind Traders.

+
+

Sales leadership

+

The Vice President of Sales, with the territories, reports and order history attached to the role.

+
"""^^rdf:XMLLiteral . <#top-manager> a ldh:Object ; + dct:title "Vice President, Sales" ; rdf:value . <#navigation-prompt> a ldh:XHTML ; rdf:value """
-

Explore more

-

Dive deeper by exploring the sections below. Start with Orders — the order book lists every order with its status and total, and each order opens into a master-detail view of its line items, delivery and the people involved. Products, customers, employees and the other sections offer their own analytics and cross-links.

+
+

Browse the business

+

Open Orders for the order book and its master–detail line items, or go straight to products, customers, employees and the reference data behind them.

+
"""^^rdf:XMLLiteral . <#select-children> a ldh:Object ; + dct:title "Sections" ; rdf:value ldh:ChildrenView . diff --git a/demo/northwind-traders/shippers.ttl b/demo/northwind-traders/shippers.ttl index 2a5b1b9..26ca03e 100644 --- a/demo/northwind-traders/shippers.ttl +++ b/demo/northwind-traders/shippers.ttl @@ -8,10 +8,21 @@ # Main container document <> a dh:Container ; dct:title "Shippers" ; - rdf:_1 <#select-shippers> . + rdf:_1 <#shippers-intro> ; + rdf:_2 <#select-shippers> . + + # Intro XHTML + <#shippers-intro> a ldh:XHTML ; + rdf:value """
+
+

Getting it there

+

The carriers that deliver Northwind's orders. Open one to see every delivery it handled.

+
+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-shippers> a ldh:Object ; + dct:title "All shippers" ; rdf:value <#select-shippers-view> . # View block (references the query, no mode specified - uses default) diff --git a/demo/northwind-traders/suppliers.ttl b/demo/northwind-traders/suppliers.ttl index 2f5b755..e8b2d7c 100644 --- a/demo/northwind-traders/suppliers.ttl +++ b/demo/northwind-traders/suppliers.ttl @@ -9,10 +9,22 @@ # Main container document <> a dh:Container ; dct:title "Suppliers" ; - rdf:_1 <#select-suppliers> . + rdf:_1 <#suppliers-intro> ; + rdf:_2 <#select-suppliers> . + + # Intro XHTML + <#suppliers-intro> a ldh:XHTML ; + rdf:value """
+
+

Where the goods come from

+

The companies Northwind buys from. Open a supplier to see everything it supplies to the + catalogue.

+
+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-suppliers> a ldh:Object ; + dct:title "All suppliers" ; rdf:value <#select-suppliers-view> . # View block (references the query, uses TableMode) diff --git a/demo/northwind-traders/territories.ttl b/demo/northwind-traders/territories.ttl index cb1575f..0489504 100644 --- a/demo/northwind-traders/territories.ttl +++ b/demo/northwind-traders/territories.ttl @@ -9,10 +9,22 @@ # Main container document <> a dh:Container ; dct:title "Territories" ; - rdf:_1 <#select-territories> . + rdf:_1 <#territories-intro> ; + rdf:_2 <#select-territories> . + + # Intro XHTML + <#territories-intro> a ldh:XHTML ; + rdf:value """
+
+

Sales territories

+

The territories reps cover, plotted on the map and grouped into + regions.

+
+
"""^^rdf:XMLLiteral . # Object block (references the view) <#select-territories> a ldh:Object ; + dct:title "All territories" ; rdf:value <#select-territories-view> . # View block (references the query, uses MapMode) From 250b827089ba6227c23a6f1c3ea8ad4140e08a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sat, 29 Aug 2026 16:24:45 +0200 Subject: [PATCH 4/8] Resolve root.ttl links against the application base in check-links.sh The checker maps every .ttl to /, which is right for containers but wrong for root.ttl: install.sh PUTs it at $base itself, not at /root/. So a relative link in a root.ttl resolved one level too deep, and demo/northwind-traders/root.ttl's href="orders/" was reported as demo/northwind-traders/root/orders.ttl. No root.ttl carried a relative link until now, which is why this went unnoticed. The link is correct at runtime; the checker's base was not. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg --- check-links.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/check-links.sh b/check-links.sh index 9912349..48f04bf 100755 --- a/check-links.sh +++ b/check-links.sh @@ -15,7 +15,16 @@ print(result[len('http://x/'):]) while IFS= read -r -d '' ttl_file; do rel="${ttl_file#"$REPO_DIR"/}" - url_path="${rel%.ttl}/" + + # A .ttl is served at / — except root.ttl, which install.sh PUTs + # at the application base itself rather than at /root/. Resolving a root.ttl's + # relative links against /root/ would send them one level too deep. + if [[ "$(basename "$rel")" == "root.ttl" ]]; then + url_path="$(dirname "$rel")/" + [[ "$url_path" == "./" ]] && url_path="" + else + url_path="${rel%.ttl}/" + fi while IFS= read -r url; do [[ "$url" =~ ^https?:// ]] && continue From 170f3b5a8cae8a7a4062e9363e65965da9b47dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 30 Aug 2026 00:31:41 +0200 Subject: [PATCH 5/8] Property URI fix --- demo/northwind-traders/products.ttl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/northwind-traders/products.ttl b/demo/northwind-traders/products.ttl index f5dd90d..bf8bd0e 100644 --- a/demo/northwind-traders/products.ttl +++ b/demo/northwind-traders/products.ttl @@ -93,7 +93,7 @@ WHERE { schema:provider ?supplier . } GRAPH ?supplierGraph { - ?supplier schema:name ?supplierName . + ?supplier schema:legalName ?supplierName . } } GROUP BY ?supplier ?supplierName From 8d14efdba305d7e2f9f1cea987b552b5a2011a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 30 Aug 2026 01:09:28 +0200 Subject: [PATCH 6/8] Model Northwind prices and deliveries with in-domain schema.org terms Several schema.org properties were carrying values outside their declared domains. Each is moved to the term the vocabulary actually provides: - Order lines are now schema:OrderItem *and* schema:Offer. schema:OrderItem has no price properties at all, so schema:price/schema:priceCurrency were off-domain; typing the line as the offer that was accepted for it makes them legal without splitting the line across two nodes. - schema:Offer's own schema:price is the price of the offer as a whole, so it replaces schema:totalPrice (a Reservation/Ticket property) as the line total. The historical unit price moves to a schema:UnitPriceSpecification with schema:priceType schema:ListPrice. - schema:discount is dropped. It is an Order-level property in schema.org, and the list price, quantity and line total already pin the per-line rate exactly - unlike a stored rate, they cannot disagree with each other. - Product prices move from schema:Product to a schema:Offer reached through schema:offers. A product is not an offer, so this one needs its own node. - Freight moves off schema:ParcelDelivery, which has no price property, onto a schema:DeliveryChargeSpecification reached via schema:priceSpecification from a shipping schema:Offer. - The dispatch date moves off schema:availableFrom (a DeliveryEvent property borrowed onto a ParcelDelivery, and meaning "available for pickup" rather than "dispatched") onto a real schema:DeliveryEvent hanging off schema:deliveryStatus. Orders never dispatched get no event at all. - schema:orderedItem loses its rdfs:domain/rdfs:range. schema.org allows Order|OrderItem -> OrderItem|Product|Service, which a single domain/range pair cannot express; the narrowed pair entailed every OrderItem is a Product. Also fixes a pre-existing import bug: shipPostalCode sat in the required BGP but 19 orders have none, so those orders never entered the graph at all. Making it OPTIONAL restores them, taking the import from 811 to 830 orders and revenue from 1,215,812 to 1,265,792 - the canonical Northwind figure. Consuming queries follow the new structure. The six revenue queries drop their ?quantity * ?price * (1 - ?discount) recomputation for the materialised line total, which also settles a 0.1 discrepancy between the carrier chart and the rest. SelectProductsFromSupplier traverses schema:offers/schema:price. Verified by running all eleven import queries over the real CSVs and all 28 view and chart queries against the resulting graph: 830 orders, 2155 lines, freight totalling 64,943, and top product/employee/customer/category all matching canonical Northwind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc --- demo/northwind-traders/admin/model/ns.ttl | 97 ++++++++++++++++--- demo/northwind-traders/categories.ttl | 5 +- demo/northwind-traders/customers.ttl | 5 +- demo/northwind-traders/employees.ttl | 5 +- demo/northwind-traders/orders.ttl | 2 +- .../northwind-traders/orders/order_details.rq | 25 ++++- demo/northwind-traders/orders/orders.rq | 49 +++++++++- demo/northwind-traders/products.ttl | 5 +- demo/northwind-traders/products/products.rq | 16 ++- demo/northwind-traders/root.ttl | 10 +- 10 files changed, 168 insertions(+), 51 deletions(-) diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index 100fef6..e3f4e0d 100644 --- a/demo/northwind-traders/admin/model/ns.ttl +++ b/demo/northwind-traders/admin/model/ns.ttl @@ -44,6 +44,32 @@ schema:ParcelDelivery a owl:Class ; rdfs:label "Delivery" ; rdfs:isDefinedBy : . +schema:DeliveryEvent a owl:Class ; + rdfs:label "Shipment" ; + rdfs:isDefinedBy : . + +schema:Offer a owl:Class ; + rdfs:label "Offer" ; + rdfs:isDefinedBy : . + +schema:PriceSpecification a owl:Class ; + rdfs:label "Price specification" ; + rdfs:isDefinedBy : . + +schema:DeliveryChargeSpecification a owl:Class ; + rdfs:label "Delivery charge" ; + rdfs:subClassOf schema:PriceSpecification ; + rdfs:isDefinedBy : . + +schema:UnitPriceSpecification a owl:Class ; + rdfs:label "Unit price" ; + rdfs:subClassOf schema:PriceSpecification ; + rdfs:isDefinedBy : . + +schema:DeliveryMethod a owl:Class ; + rdfs:label "Delivery method" ; + rdfs:isDefinedBy : . + schema:OrderItem a owl:Class ; rdfs:label "Order item" ; rdfs:isDefinedBy : . @@ -100,6 +126,53 @@ schema:orderDelivery a owl:ObjectProperty ; rdfs:range schema:ParcelDelivery ; rdfs:isDefinedBy : . +schema:acceptedOffer a owl:ObjectProperty ; + rdfs:label "Accepted offer" ; + rdfs:domain schema:Order ; + rdfs:range schema:Offer ; + rdfs:isDefinedBy : . + +schema:offers a owl:ObjectProperty ; + rdfs:label "Offer" ; + rdfs:domain schema:Product ; + rdfs:range schema:Offer ; + rdfs:isDefinedBy : . + +schema:priceType a owl:ObjectProperty ; + rdfs:label "Price type" ; + rdfs:domain schema:UnitPriceSpecification ; + rdfs:isDefinedBy : . + +schema:itemOffered a owl:ObjectProperty ; + rdfs:label "Product" ; + rdfs:domain schema:Offer ; + rdfs:range schema:Product ; + rdfs:isDefinedBy : . + +schema:priceSpecification a owl:ObjectProperty ; + rdfs:label "Price specification" ; + rdfs:domain schema:Offer ; + rdfs:range schema:PriceSpecification ; + rdfs:isDefinedBy : . + +schema:appliesToDeliveryMethod a owl:ObjectProperty ; + rdfs:label "Delivery method" ; + rdfs:domain schema:DeliveryChargeSpecification ; + rdfs:range schema:DeliveryMethod ; + rdfs:isDefinedBy : . + +schema:deliveryStatus a owl:ObjectProperty ; + rdfs:label "Shipment" ; + rdfs:domain schema:ParcelDelivery ; + rdfs:range schema:DeliveryEvent ; + rdfs:isDefinedBy : . + +schema:partOfOrder a owl:ObjectProperty ; + rdfs:label "Order" ; + rdfs:domain schema:ParcelDelivery ; + rdfs:range schema:Order ; + rdfs:isDefinedBy : . + schema:deliveryAddress a owl:ObjectProperty ; rdfs:label "Delivery address" ; rdfs:domain schema:ParcelDelivery ; @@ -131,8 +204,6 @@ schema:sponsor a owl:ObjectProperty ; schema:orderedItem a owl:ObjectProperty ; rdfs:label "Ordered item" ; - rdfs:domain schema:Order ; - rdfs:range schema:Product ; rdfs:isDefinedBy : . schema:orderStatus a owl:ObjectProperty ; @@ -220,22 +291,19 @@ schema:orderQuantity a owl:DatatypeProperty ; rdfs:domain schema:OrderItem ; rdfs:isDefinedBy : . -schema:discount a owl:DatatypeProperty ; - rdfs:label "Discount" ; - rdfs:domain schema:OrderItem ; - rdfs:isDefinedBy : . - +# left without an rdfs:domain on purpose: schema:price is carried both by the order +# lines (as schema:Offer) and by the freight charge (as schema:DeliveryChargeSpecification) schema:price a owl:DatatypeProperty ; rdfs:label "Price" ; rdfs:isDefinedBy : . -schema:totalPrice a owl:DatatypeProperty ; - rdfs:label "Total price" ; +schema:priceCurrency a owl:DatatypeProperty ; + rdfs:label "Currency" ; rdfs:isDefinedBy : . -schema:availableFrom a owl:DatatypeProperty ; - rdfs:label "Shipped date" ; - rdfs:domain schema:ParcelDelivery ; +schema:startDate a owl:DatatypeProperty ; + rdfs:label "Shipped" ; + rdfs:domain schema:DeliveryEvent ; rdfs:isDefinedBy : . schema:orderDate a owl:DatatypeProperty ; @@ -354,7 +422,7 @@ WHERE { ?product a schema:Product ; schema:provider $about ; schema:category ?category ; - schema:price ?price + schema:offers/schema:price ?price } } ORDER BY ?product @@ -440,8 +508,7 @@ WHERE { $about schema:orderedItem ?item . ?item schema:orderedItem ?product ; schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:totalPrice ?lineTotal + schema:price ?lineTotal } } ORDER BY ?item diff --git a/demo/northwind-traders/categories.ttl b/demo/northwind-traders/categories.ttl index eab98fa..e4fb038 100644 --- a/demo/northwind-traders/categories.ttl +++ b/demo/northwind-traders/categories.ttl @@ -57,10 +57,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount . - BIND(?quantity * ?price * (1 - ?discount) AS ?sale) + schema:price ?sale . } GRAPH ?productGraph { ?product schema:category ?category . diff --git a/demo/northwind-traders/customers.ttl b/demo/northwind-traders/customers.ttl index b88eff3..ba3d43d 100644 --- a/demo/northwind-traders/customers.ttl +++ b/demo/northwind-traders/customers.ttl @@ -47,10 +47,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:customer ?customer ; schema:orderedItem ?orderItem . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount . - BIND(?quantity * ?price * (1 - ?discount) AS ?sale) + ?orderItem schema:price ?sale . } GRAPH ?customerGraph { ?customer schema:legalName ?companyName . diff --git a/demo/northwind-traders/employees.ttl b/demo/northwind-traders/employees.ttl index 308e9ce..ffe4ca5 100644 --- a/demo/northwind-traders/employees.ttl +++ b/demo/northwind-traders/employees.ttl @@ -55,10 +55,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:broker ?employee ; schema:orderedItem ?orderItem . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount . - BIND(?quantity * ?price * (1 - ?discount) AS ?sale) + ?orderItem schema:price ?sale . } GRAPH ?employeeGraph { ?employee schema:givenName ?givenName ; diff --git a/demo/northwind-traders/orders.ttl b/demo/northwind-traders/orders.ttl index 3b111d5..b8f26ef 100644 --- a/demo/northwind-traders/orders.ttl +++ b/demo/northwind-traders/orders.ttl @@ -174,7 +174,7 @@ WHERE { schema:orderDate ?orderDate ; schema:orderDelivery ?delivery ; schema:orderedItem ?orderItem . - ?orderItem schema:totalPrice ?lineTotal . + ?orderItem schema:price ?lineTotal . ?delivery schema:provider ?shipper . } GRAPH ?shipperGraph { diff --git a/demo/northwind-traders/orders/order_details.rq b/demo/northwind-traders/orders/order_details.rq index 814b1c3..4533114 100644 --- a/demo/northwind-traders/orders/order_details.rq +++ b/demo/northwind-traders/orders/order_details.rq @@ -7,16 +7,32 @@ CONSTRUCT { GRAPH ?graph { - ?graph foaf:topic ?orderItem . + ?graph foaf:topic ?orderItem, ?listPrice . ?order schema:orderedItem ?orderItem . - ?orderItem a schema:OrderItem ; + # schema:OrderItem carries no price in schema.org - its only properties are + # orderedItem, orderQuantity, orderItemNumber, orderItemStatus and orderDelivery. + # Prices belong to schema:Offer, so the line is both: the item that was ordered + # and the offer that was accepted for it. schema:Offer's own schema:price is the + # price of the offer as a whole, i.e. what this line came to. + ?orderItem a schema:OrderItem, schema:Offer ; schema:orderedItem ?product ; + schema:itemOffered ?product ; schema:orderQuantity ?quantity ; - schema:discount ?discount ; + schema:price ?lineTotal ; + schema:priceCurrency "USD" ; + schema:priceSpecification ?listPrice . + + # Northwind's per-line discount rate has no home in schema.org - schema:discount + # is an Order-level property. Nothing is lost by dropping it: the undiscounted + # unit price below, the quantity above and the line total pin the rate exactly, + # and unlike a stored rate they cannot disagree with each other. + ?listPrice a schema:UnitPriceSpecification ; + dct:title "List price" ; + schema:priceType schema:ListPrice ; schema:price ?price ; - schema:totalPrice ?lineTotal . + schema:priceCurrency "USD" . } } WHERE @@ -30,6 +46,7 @@ WHERE BIND(uri(concat(str($base), "orders/", encode_for_uri(?orderID), "/")) AS ?graph) BIND(uri(concat(str(?graph), "#this")) AS ?order) BIND(uri(concat(str(?graph), "#", STRUUID())) AS ?orderItem) + BIND(uri(concat(str(?orderItem), "-list-price")) AS ?listPrice) BIND(uri(concat(str($base), "products/", encode_for_uri(?productID), "/#this")) AS ?product) BIND (STRDT(?unitPrice, xsd:float) AS ?price) BIND (STRDT(?quantityString, xsd:integer) AS ?quantity) diff --git a/demo/northwind-traders/orders/orders.rq b/demo/northwind-traders/orders/orders.rq index b53f483..9db16ff 100644 --- a/demo/northwind-traders/orders/orders.rq +++ b/demo/northwind-traders/orders/orders.rq @@ -9,7 +9,8 @@ CONSTRUCT { ?graph dct:title ?orderID ; foaf:primaryTopic ?order ; - foaf:topic ?orderDelivery, ?deliveryAddress . + foaf:topic ?orderDelivery, ?deliveryAddress, ?shipment, + ?shippingOffer, ?freightCharge . ?order a schema:Order ; schema:identifier ?orderID ; @@ -18,16 +19,41 @@ CONSTRUCT schema:broker ?employee ; schema:orderDate ?orderDate ; schema:orderStatus ?status ; + schema:acceptedOffer ?shippingOffer ; schema:orderDelivery ?orderDelivery . ?orderDelivery a schema:ParcelDelivery ; foaf:page ?graph ; + schema:partOfOrder ?order ; schema:expectedArrivalUntil ?requiredDate ; - schema:availableFrom ?shippedDate ; - schema:price ?freight ; + schema:deliveryStatus ?shipment ; schema:deliveryAddress ?deliveryAddress ; schema:provider ?shipper . + # schema:ParcelDelivery has no price property either. A delivery charge is a + # schema:DeliveryChargeSpecification, and the only way into a PriceSpecification + # is schema:priceSpecification from an Offer - so the freight the customer + # accepted is spelled out as exactly that. + ?shippingOffer a schema:Offer ; + foaf:page ?graph ; + dct:title "Shipping" ; + schema:priceSpecification ?freightCharge . + + ?freightCharge a schema:DeliveryChargeSpecification ; + foaf:page ?graph ; + dct:title "Freight" ; + schema:price ?freight ; + schema:priceCurrency "USD" ; + schema:appliesToDeliveryMethod schema:ParcelService . + + # schema:ParcelDelivery has no date of its own for "when did this leave the warehouse". + # The vocabulary models the journey as a series of schema:DeliveryEvent legs hanging + # off schema:deliveryStatus; Northwind knows exactly one leg, the dispatch. + ?shipment a schema:DeliveryEvent ; + foaf:page ?graph ; + dct:title ?shipmentTitle ; + schema:startDate ?shippedDate . + ?deliveryAddress a schema:PostalAddress ; foaf:page ?graph ; schema:addressCountry ?shipCountry ; @@ -49,22 +75,35 @@ WHERE <#shipName> ?shipName ; <#shipAddress> ?shipAddress ; <#shipCity> ?shipCity ; - <#shipPostalCode> ?shipPostalCode ; <#shipCountry> ?shipCountry . + # ?shipment is bound inside the OPTIONAL so that the 21 orders that were never + # dispatched get no DeliveryEvent at all, rather than an empty one. It is built from + # $base (a constant, substituted before execution) and a re-matched ?orderID rather + # than from ?graph: an OPTIONAL group is evaluated standalone, so variables BINDed in + # the enclosing group are not in scope inside it. OPTIONAL { - ?order_row <#shippedDate> ?shippedDateString + ?order_row <#orderID> ?orderID ; + <#shippedDate> ?shippedDateString . BIND(strdt(?shippedDateString, xsd:date) AS ?shippedDate) + BIND(uri(concat(str($base), "orders/", encode_for_uri(?orderID), "/#shipment")) AS ?shipment) + BIND(concat("Shipped ", ?shippedDateString) AS ?shipmentTitle) } OPTIONAL { ?order_row <#shipRegion> ?shipRegion } + # 19 orders carry no postal code; requiring one here dropped them from the import + OPTIONAL { + ?order_row <#shipPostalCode> ?shipPostalCode + } BIND(uri(concat(str($base), "orders/")) AS ?container) BIND(uri(concat(str(?container), encode_for_uri(?orderID), "/")) AS ?graph) BIND(uri(concat(str(?graph), "#this")) AS ?order) BIND(uri(concat(str(?graph), "#delivery")) AS ?orderDelivery) BIND(uri(concat(str(?graph), "#delivery-address")) AS ?deliveryAddress) + BIND(uri(concat(str(?graph), "#shipping")) AS ?shippingOffer) + BIND(uri(concat(str(?graph), "#freight")) AS ?freightCharge) BIND(uri(concat(str($base), "employees/", encode_for_uri(?employeeID), "/#this")) AS ?employee) BIND(uri(concat(str($base), "customers/", encode_for_uri(?customerID), "/#this")) AS ?customer) BIND(uri(concat(str($base), "shippers/", encode_for_uri(?shipVia), "/#this")) AS ?shipper) diff --git a/demo/northwind-traders/products.ttl b/demo/northwind-traders/products.ttl index bf8bd0e..3397a25 100644 --- a/demo/northwind-traders/products.ttl +++ b/demo/northwind-traders/products.ttl @@ -58,10 +58,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount - BIND (?quantity * ?price * (1 - ?discount) AS ?sale) + schema:price ?sale } GRAPH ?productGraph { ?product schema:name ?productName } diff --git a/demo/northwind-traders/products/products.rq b/demo/northwind-traders/products/products.rq index 1f482db..fa0000a 100644 --- a/demo/northwind-traders/products/products.rq +++ b/demo/northwind-traders/products/products.rq @@ -8,7 +8,8 @@ CONSTRUCT GRAPH ?graph { ?graph dct:title ?productName ; - foaf:primaryTopic ?product . + foaf:primaryTopic ?product ; + foaf:topic ?offer . ?product a schema:Product ; schema:identifier ?productID ; @@ -17,11 +18,21 @@ CONSTRUCT schema:provider ?supplier ; schema:category ?category ; schema:description ?quantityPerUnit ; - schema:price ?unitPrice . + schema:offers ?offer . # ?unitsInStock ; # ?unitsOnOrder ; # ?reorderLevel ; # ?discontinued - schema:Discontinued + + # schema:Product has no price of its own - schema:price belongs to schema:Offer, + # which a product reaches through schema:offers. This is the catalogue price; + # what a given order line was actually billed at lives on that line's own offer. + ?offer a schema:Offer ; + foaf:page ?graph ; + dct:title "List price" ; + schema:itemOffered ?product ; + schema:price ?unitPrice ; + schema:priceCurrency "USD" . } } WHERE @@ -40,6 +51,7 @@ WHERE BIND (uri(concat(str($base), "products/")) AS ?container) BIND(uri(concat(str(?container), encode_for_uri(?productID), "/")) AS ?graph) BIND(uri(concat(str(?graph), "#this")) AS ?product) + BIND(uri(concat(str(?graph), "#offer")) AS ?offer) BIND(uri(concat(str($base), "suppliers/", encode_for_uri(?supplierID), "/#this")) AS ?supplier) BIND(uri(concat(str($base), "categories/", encode_for_uri(?categoryID), "/#this")) AS ?category) BIND (STRDT(?unitPriceString, xsd:float) AS ?unitPrice) diff --git a/demo/northwind-traders/root.ttl b/demo/northwind-traders/root.ttl index 877845a..717d3f8 100644 --- a/demo/northwind-traders/root.ttl +++ b/demo/northwind-traders/root.ttl @@ -50,10 +50,7 @@ WHERE { ?order a schema:Order ; schema:orderDate ?orderDate ; schema:orderedItem ?orderItem . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount . - BIND(?quantity * ?price * (1 - ?discount) AS ?sale) + ?orderItem schema:price ?sale . BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) } # The order book stops part-way through its final month, which would render as a @@ -90,12 +87,9 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem ; schema:orderDelivery ?delivery . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price ; - schema:discount ?discount . + ?orderItem schema:price ?sale . ?delivery schema:deliveryAddress ?address . ?address schema:addressCountry ?country . - BIND(?quantity * ?price * (1 - ?discount) AS ?sale) } } GROUP BY ?country From f9468165fe7d0f4636d7934b1dc31889473ffd04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 30 Aug 2026 02:09:33 +0200 Subject: [PATCH 7/8] Put orderStatus, line totals and dispatch dates to work on the front and orders pages The schema.org remodelling added facts the content blocks were not using: per-order status, a materialised line total, the catalogue price each line was struck against, and a DeliveryEvent carrying the dispatch date. Four new charts read them, each with a narrative block ahead of it. Front page gains a discounting section. Revenue is what was billed; the ListPrice specification on each line is what was listed, so the gap is what was given away to win the deal. As a share of gross it runs 3.6%-11.2% and is decorrelated from volume, which a revenue chart cannot show. Orders page gains a fulfilment section - average days from order to dispatch, and the share of orders that beat the date the customer was promised - plus a late-shipments-by-carrier bar alongside the existing revenue-by-carrier line, so carriers are compared on reliability as well as volume. "Orders by country" is dropped to make room. The front page already tells the geographic story in revenue rather than order count, which is the more useful of the two measures. Both rate charts are normalised on purpose. Raw monthly status counts squash the late series (0-4 a month) against a delivered series climbing 21 to 69, and the 21 still-open orders are a dataset-cutoff artifact confined to the last two months - they are filtered out of the on-time and late rates, since not yet dispatched is not the same as delivered late. Also corrects the orders intro, which still promised unit prices in the line-items table; those moved to a nested UnitPriceSpecification. Verified against the full graph built from the real CSVs: 31 view and chart queries, none failing to parse, none returning no rows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc --- demo/northwind-traders/orders.ttl | 163 ++++++++++++++++++++++++------ demo/northwind-traders/root.ttl | 75 ++++++++++++-- 2 files changed, 198 insertions(+), 40 deletions(-) diff --git a/demo/northwind-traders/orders.ttl b/demo/northwind-traders/orders.ttl index b8f26ef..e5c570d 100644 --- a/demo/northwind-traders/orders.ttl +++ b/demo/northwind-traders/orders.ttl @@ -13,10 +13,12 @@ rdf:_2 <#select-orders> ; rdf:_3 <#analytics-intro> ; rdf:_4 <#orders-over-time-block> ; - rdf:_5 <#geographic-intro> ; - rdf:_6 <#orders-by-country-block> ; - rdf:_7 <#carrier-intro> ; - rdf:_8 <#revenue-by-carrier-block> . + rdf:_5 <#fulfilment-intro> ; + rdf:_6 <#days-to-ship-block> ; + rdf:_7 <#on-time-block> ; + rdf:_8 <#carrier-intro> ; + rdf:_9 <#late-by-carrier-block> ; + rdf:_10 <#revenue-by-carrier-block> . # Intro XHTML <#orders-intro> a ldh:XHTML ; @@ -25,9 +27,9 @@

The order book

Every order links a customer, the sales rep who booked it, the shipper that delivered it and the products it contains. Orders are listed newest first with their status — sort any column or - filter with the facets. Open one for its full master–detail view: line items with quantities, - unit prices and line totals, the delivery and its address, and the people and companies - involved.

+ filter with the facets. Open one for its full master–detail view: line items with their + quantities and line totals, the catalogue price each was struck against, the delivery + and its address, and the people and companies involved.

"""^^rdf:XMLLiteral . @@ -95,54 +97,149 @@ WHERE { GROUP BY ?month ORDER BY ?month""" . - # Geographic intro - <#geographic-intro> a ldh:XHTML ; + # Fulfilment intro + <#fulfilment-intro> a ldh:XHTML ; rdf:value """
-

Geographic distribution

-

And where do the orders go? Each order's delivery address places it in a destination country, - showing where Northwind's markets are strongest.

+

From order to doorstep

+

Every order records three dates: the day it was placed, the day the customer needed it + by, and — once the parcel actually leaves the warehouse — the day it was dispatched. + Two questions fall out of those. How quickly does Northwind get an order out of the + door, and how often does it beat the date it promised? Orders still waiting to ship + are left out of the second chart, since they have no outcome yet.

"""^^rdf:XMLLiteral . - # Orders by country chart (wrapped in ldh:Object per ContentMode contract) - <#orders-by-country-block> a ldh:Object ; - dct:title "Orders by country" ; - rdf:value <#orders-by-country> . + # Days from order to dispatch + <#days-to-ship-block> a ldh:Object ; + dct:title "Days to ship" ; + rdf:value <#days-to-ship> . - <#orders-by-country> a ldh:ResultSetChart ; - dct:title "Orders by country" ; - spin:query <#orders-by-country-query> ; - ldh:chartType ; - ldh:categoryVarName "country" ; - ldh:seriesVarName "orderCount" . + <#days-to-ship> a ldh:ResultSetChart ; + dct:title "Days to ship" ; + spin:query <#days-to-ship-query> ; + ldh:chartType ; + ldh:categoryVarName "month" ; + ldh:seriesVarName "daysToShip" . + + <#days-to-ship-query> a sp:Select ; + dct:title "Days to ship by month" ; + sp:text """PREFIX schema: +PREFIX xsd: + +SELECT ?month (ROUND(AVG(?days) * 10) / 10 AS ?daysToShip) +WHERE { + GRAPH ?orderGraph { + ?order a schema:Order ; + schema:orderDate ?orderDate ; + schema:orderDelivery ?delivery . + # the dispatch is a DeliveryEvent leg of the parcel's journey, so orders that + # never shipped simply have no event and drop out of the average + ?delivery schema:deliveryStatus ?shipment . + ?shipment schema:startDate ?shippedDate . + BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) + BIND((?shippedDate - ?orderDate) / xsd:dayTimeDuration("P1D") AS ?days) + } + # The order book stops part-way through its final month, which would render as a + # cliff rather than a trend. Drop that month — derived from the data, not pinned + # to a date, so it stays correct if the dataset grows. + { + SELECT (SUBSTR(STR(MAX(?anyDate)), 1, 7) AS ?partialMonth) + WHERE { + GRAPH ?g { ?anyOrder a schema:Order ; schema:orderDate ?anyDate } + } + } + FILTER (?month < ?partialMonth) +} +GROUP BY ?month +ORDER BY ?month""" . + + # On-time shipping rate + <#on-time-block> a ldh:Object ; + dct:title "Shipped on time" ; + rdf:value <#on-time> . + + <#on-time> a ldh:ResultSetChart ; + dct:title "Shipped on time" ; + spin:query <#on-time-query> ; + ldh:chartType ; + ldh:categoryVarName "month" ; + ldh:seriesVarName "onTimePercent" . - <#orders-by-country-query> a sp:Select ; - dct:title "Orders by country" ; + <#on-time-query> a sp:Select ; + dct:title "On-time rate by month" ; sp:text """PREFIX schema: -SELECT ?country (COUNT(DISTINCT ?order) AS ?orderCount) +SELECT ?month (ROUND(SUM(IF(?status = schema:OrderDelivered, 1, 0)) * 1000 / COUNT(?order)) / 10 AS ?onTimePercent) WHERE { GRAPH ?orderGraph { - ?order schema:orderDelivery ?delivery . - ?delivery schema:deliveryAddress ?address . - ?address schema:addressCountry ?country . + ?order a schema:Order ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status . + BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) + } + # an order still awaiting dispatch has no outcome yet - counting it as a miss would + # drag the rate down for the months at the end of the book + FILTER (?status != schema:OrderProcessing) + # The order book stops part-way through its final month, which would render as a + # cliff rather than a trend. Drop that month — derived from the data, not pinned + # to a date, so it stays correct if the dataset grows. + { + SELECT (SUBSTR(STR(MAX(?anyDate)), 1, 7) AS ?partialMonth) + WHERE { + GRAPH ?g { ?anyOrder a schema:Order ; schema:orderDate ?anyDate } + } } + FILTER (?month < ?partialMonth) } -GROUP BY ?country -ORDER BY DESC(?orderCount) -LIMIT 10""" . +GROUP BY ?month +ORDER BY ?month""" . # Carrier intro <#carrier-intro> a ldh:XHTML ; rdf:value """

Who carries the freight

-

Three carriers deliver every order. Their monthly revenue shows how the - shipping mix has shifted.

+

Three carriers deliver every order, which makes them worth comparing on two + axes. How often does a carrier miss the date the customer was promised, and + how much revenue does each one move month to month? The first is a question + about reliability, the second about how the shipping mix has shifted.

"""^^rdf:XMLLiteral . + # Late shipments by carrier + <#late-by-carrier-block> a ldh:Object ; + dct:title "Late shipments by carrier" ; + rdf:value <#late-by-carrier> . + + <#late-by-carrier> a ldh:ResultSetChart ; + dct:title "Late shipments by carrier" ; + spin:query <#late-by-carrier-query> ; + ldh:chartType ; + ldh:categoryVarName "carrier" ; + ldh:seriesVarName "latePercent" . + + <#late-by-carrier-query> a sp:Select ; + dct:title "Late shipments by carrier" ; + sp:text """PREFIX schema: + +SELECT ?carrier (ROUND(SUM(IF(?status = schema:OrderProblem, 1, 0)) * 1000 / COUNT(?order)) / 10 AS ?latePercent) +WHERE { + GRAPH ?orderGraph { + ?order a schema:Order ; + schema:orderStatus ?status ; + schema:orderDelivery ?delivery . + ?delivery schema:provider ?shipper . + } + GRAPH ?shipperGraph { + ?shipper schema:legalName ?carrier . + } + # not yet dispatched is not the same as delivered late + FILTER (?status != schema:OrderProcessing) +} +GROUP BY ?carrier +ORDER BY DESC(?latePercent)""" . + # Revenue by carrier - the one multi-series chart in the app. Each ldh:seriesVarName # names its own measure column (wide format); the query pivots with conditional # aggregation. Three carriers is a small fixed set, so naming them here is tolerable - diff --git a/demo/northwind-traders/root.ttl b/demo/northwind-traders/root.ttl index 717d3f8..6a041eb 100644 --- a/demo/northwind-traders/root.ttl +++ b/demo/northwind-traders/root.ttl @@ -14,18 +14,21 @@ dct:description "Knowledge Graph representation of the Northwind Traders sample database" ; rdf:_1 <#overview-intro> ; rdf:_2 <#sales-trend-block> ; - rdf:_3 <#revenue-by-country-block> ; - rdf:_4 <#top-selling-products> ; - rdf:_5 <#top-manager-header> ; - rdf:_6 <#top-manager> ; - rdf:_7 <#navigation-prompt> ; - rdf:_8 <#select-children> . + rdf:_3 <#margin-intro> ; + rdf:_4 <#discount-rate-block> ; + rdf:_5 <#revenue-by-country-block> ; + rdf:_6 <#top-selling-products> ; + rdf:_7 <#top-manager-header> ; + rdf:_8 <#top-manager> ; + rdf:_9 <#navigation-prompt> ; + rdf:_10 <#select-children> . <#overview-intro> a ldh:XHTML ; rdf:value """

Executive dashboard

-

Sales performance at a glance — revenue trends, market distribution and product mix.

+

Sales performance at a glance — how revenue is trending, what the discounting costs, + where the markets are and which products carry the mix.

"""^^rdf:XMLLiteral . @@ -67,6 +70,64 @@ WHERE { GROUP BY ?month ORDER BY ?month""" . +<#margin-intro> a ldh:XHTML ; + rdf:value """
+
+

What the deals cost

+

Revenue is what was billed, not what was listed. Every order line keeps the catalogue + price it started from alongside the price it actually went out at, so the gap between + them is what the sales rep gave away to win the deal. Measured against gross, that gap + swings between roughly 4% and 11% month to month — a commercial lever in its own + right, and one that never shows up in a revenue chart.

+
+
"""^^rdf:XMLLiteral . + +<#discount-rate-block> a ldh:Object ; + dct:title "Discount rate" ; + rdf:value <#discount-rate> . + +<#discount-rate> a ldh:ResultSetChart ; + dct:title "Discount rate" ; + spin:query <#discount-by-month-query> ; + ldh:chartType ; + ldh:categoryVarName "month" ; + ldh:seriesVarName "discountPercent" . + +<#discount-by-month-query> a sp:Select ; + dct:title "Discount rate by month" ; + sp:text """PREFIX schema: + +SELECT ?month (ROUND(SUM(?discount) * 1000 / SUM(?gross)) / 10 AS ?discountPercent) +WHERE { + GRAPH ?orderGraph { + ?order a schema:Order ; + schema:orderDate ?orderDate ; + schema:orderedItem ?orderItem . + # the line's own price is net of discount; the catalogue price it was struck + # against hangs off it as a ListPrice specification + ?orderItem schema:orderQuantity ?quantity ; + schema:price ?net ; + schema:priceSpecification ?listPrice . + ?listPrice schema:priceType schema:ListPrice ; + schema:price ?unitPrice . + BIND(SUBSTR(STR(?orderDate), 1, 7) AS ?month) + BIND(?quantity * ?unitPrice AS ?gross) + BIND(?gross - ?net AS ?discount) + } + # The order book stops part-way through its final month, which would render as a + # cliff rather than a trend. Drop that month — derived from the data, not pinned + # to a date, so it stays correct if the dataset grows. + { + SELECT (SUBSTR(STR(MAX(?anyDate)), 1, 7) AS ?partialMonth) + WHERE { + GRAPH ?g { ?anyOrder a schema:Order ; schema:orderDate ?anyDate } + } + } + FILTER (?month < ?partialMonth) +} +GROUP BY ?month +ORDER BY ?month""" . + <#revenue-by-country-block> a ldh:Object ; dct:title "Revenue by country" ; rdf:value <#revenue-by-country> . From 7a6fb5f928e7bb398b3795773edd486164b7ed0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 30 Aug 2026 10:33:01 +0200 Subject: [PATCH 8/8] Stop dropping the one customer that has no postal code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customers.rq required <#postalCode> in its main BGP, but HUNGO — Hungry Owl All-Night Grocers, Cork — has none. Ireland had no national postcode system until Eircode in 2015, so a 1996-98 Irish address legitimately has no postal code; this is correct data, not dirty data. The row processor emits no triple for an empty cell, so the BGP failed to match and the entire customer was silently discarded - company, contact, address, phone and coordinates alike. Its 19 orders kept their schema:customer link, which then resolved to nothing. Moving the column to an OPTIONAL, alongside region, fax, lat and long in that same query, restores the customer and takes dangling customer references from 19 to 0. HUNGO imports with its city and no postal code, rather than a fabricated placeholder. Same failure mode as the 19 orders dropped on an empty shipPostalCode. An audit of all eleven mappings against their CSVs confirms these were the only two required-but-sometimes-empty columns. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc --- demo/northwind-traders/customers/customers.rq | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/demo/northwind-traders/customers/customers.rq b/demo/northwind-traders/customers/customers.rq index 20c4dc1..5f17799 100644 --- a/demo/northwind-traders/customers/customers.rq +++ b/demo/northwind-traders/customers/customers.rq @@ -52,13 +52,17 @@ WHERE <#contactTitle> ?contactTitle ; <#address> ?address ; <#city> ?city ; - <#postalCode> ?postalCode ; <#country> ?country ; <#phone> ?phone . OPTIONAL { ?customer_row <#region> ?region } + # Ireland had no postcodes until Eircode in 2015, so HUNGO in Cork has none; + # requiring one here dropped the customer and left its 19 orders pointing at nothing + OPTIONAL { + ?customer_row <#postalCode> ?postalCode + } OPTIONAL { ?customer_row <#fax> ?fax }