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 diff --git a/demo/northwind-traders/admin/model/ns.ttl b/demo/northwind-traders/admin/model/ns.ttl index 3f5e351..e3f4e0d 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,33 @@ schema:ContactPoint a owl:Class ; rdfs:isDefinedBy : . schema:ParcelDelivery a owl:Class ; - rdfs:label "Parcel delivery" ; + 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 ; @@ -53,19 +79,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,11 +121,58 @@ 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 : . +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 ; @@ -118,20 +191,24 @@ 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 : . schema:orderedItem a owl:ObjectProperty ; rdfs:label "Ordered item" ; - 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 ; @@ -147,7 +224,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 : . @@ -210,26 +287,32 @@ schema:birthDate a owl:DatatypeProperty ; rdfs:isDefinedBy : . schema:orderQuantity a owl:DatatypeProperty ; - rdfs:label "Order quantity" ; - rdfs:domain schema:OrderItem ; - rdfs:isDefinedBy : . - -schema:discount a owl:DatatypeProperty ; - rdfs:label "Discount" ; + rdfs:label "Quantity" ; 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:priceCurrency a owl:DatatypeProperty ; + rdfs:label "Currency" ; + rdfs:isDefinedBy : . + +schema:startDate a owl:DatatypeProperty ; + rdfs:label "Shipped" ; + rdfs:domain schema:DeliveryEvent ; + rdfs:isDefinedBy : . + schema:orderDate a owl:DatatypeProperty ; rdfs:label "Order date" ; rdfs:domain schema:Order ; rdfs:isDefinedBy : . schema:expectedArrivalUntil a owl:DatatypeProperty ; - rdfs:label "Expected arrival until" ; + rdfs:label "Required by" ; rdfs:domain schema:ParcelDelivery ; rdfs:isDefinedBy : . @@ -265,6 +348,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 +359,12 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:broker $about } + { ?order schema:broker $about ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status + } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . @@ -290,6 +377,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 +389,12 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:customer $about } + { ?order schema:customer $about ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status + } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . @@ -315,6 +407,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 +419,45 @@ PREFIX schema: SELECT DISTINCT ?product WHERE { GRAPH ?graph - { ?product schema:provider $about } + { ?product a schema:Product ; + schema:provider $about ; + schema:category ?category ; + schema:offers/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 . + ?delivery schema:provider $about + } + } +ORDER BY DESC(?orderDate) +""" ; + rdfs:isDefinedBy : . + # containedInPlace # cities in this region @@ -340,6 +467,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 +487,34 @@ 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 ?lineTotal + } + } +ORDER BY ?item +""" ; + rdfs:isDefinedBy : . + # orders containing this product schema:orderedItem ldh:inverseView :OrdersContainingProduct . @@ -365,6 +522,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 +534,12 @@ PREFIX schema: SELECT DISTINCT ?order WHERE { GRAPH ?graph - { ?order schema:orderedItem ?orderItem . + { ?order schema:orderedItem ?orderItem ; + schema:orderDate ?orderDate ; + schema:orderStatus ?status . ?orderItem schema:orderedItem $about } } -ORDER BY DESC(?order) +ORDER BY DESC(?orderDate) """ ; rdfs:isDefinedBy : . diff --git a/demo/northwind-traders/categories.ttl b/demo/northwind-traders/categories.ttl index 04e071d..e4fb038 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 ; @@ -43,9 +57,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + schema:price ?sale . } GRAPH ?productGraph { ?product schema:category ?category . @@ -60,13 +72,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 +110,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..ba3d43d 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 ; @@ -44,9 +47,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:customer ?customer ; schema:orderedItem ?orderItem . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + ?orderItem schema:price ?sale . } GRAPH ?customerGraph { ?customer schema:legalName ?companyName . @@ -59,13 +60,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 +98,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/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 } diff --git a/demo/northwind-traders/employees.ttl b/demo/northwind-traders/employees.ttl index ec672cc..ffe4ca5 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 ; @@ -41,9 +55,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:broker ?employee ; schema:orderedItem ?orderItem . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . - BIND(?quantity * ?price AS ?sale) + ?orderItem schema:price ?sale . } GRAPH ?employeeGraph { ?employee schema:givenName ?givenName ; @@ -56,6 +68,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 e9f32e5..e5c570d 100644 --- a/demo/northwind-traders/orders.ttl +++ b/demo/northwind-traders/orders.ttl @@ -10,15 +10,32 @@ <> 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 <#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 ; + rdf:value """
+
+

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 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 . # 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) @@ -38,21 +55,24 @@ schema:identifier ?orderID ; schema:orderDate ?orderDate ; schema:customer ?customer ; - schema:broker ?employee + schema:broker ?employee ; + schema:orderStatus ?status } } - 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.

+
+

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 ; @@ -77,86 +97,195 @@ 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

-

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

+
+

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 ; - 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" . - <#orders-by-country-query> a sp:Select ; - dct:title "Orders by country" ; + <#days-to-ship-query> a sp:Select ; + dct:title "Days to ship by month" ; sp:text """PREFIX schema: +PREFIX xsd: -SELECT ?country (COUNT(DISTINCT ?order) AS ?orderCount) +SELECT ?month (ROUND(AVG(?days) * 10) / 10 AS ?daysToShip) WHERE { GRAPH ?orderGraph { - ?order schema:orderDelivery ?delivery . - ?delivery schema:deliveryAddress ?address . - ?address schema:addressCountry ?country . + ?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" . + + <#on-time-query> a sp:Select ; + dct:title "On-time rate by month" ; + sp:text """PREFIX schema: + +SELECT ?month (ROUND(SUM(IF(?status = schema:OrderDelivered, 1, 0)) * 1000 / COUNT(?order)) / 10 AS ?onTimePercent) +WHERE { + GRAPH ?orderGraph { + ?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""" . - - # 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 ; +GROUP BY ?month +ORDER BY ?month""" . + + # Carrier intro + <#carrier-intro> 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.

+
+

Who carries the freight

+

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 . - # 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""" . + # 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 - + # 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:price ?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/orders/order_details.rq b/demo/northwind-traders/orders/order_details.rq index 30771e9..4533114 100644 --- a/demo/northwind-traders/orders/order_details.rq +++ b/demo/northwind-traders/orders/order_details.rq @@ -7,13 +7,32 @@ CONSTRUCT { GRAPH ?graph { + ?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 ?price . + 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:priceCurrency "USD" . } } WHERE @@ -27,8 +46,10 @@ 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) 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..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 ; @@ -17,22 +18,48 @@ CONSTRUCT schema:customer ?customer ; 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 ; - # ?shippedDate ; + 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 ; schema:addressLocality ?shipCity ; schema:postalCode ?shipPostalCode ; schema:streetAddress ?shipAddress ; - schema:location ?deliveryLocation ; schema:addressRegion ?shipRegion . } } @@ -48,26 +75,42 @@ 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) 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/products.ttl b/demo/northwind-traders/products.ttl index 84ba577..3397a25 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 ; @@ -47,9 +58,7 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem . ?orderItem schema:orderedItem ?product ; - schema:orderQuantity ?quantity ; - schema:price ?price - BIND (?quantity * ?price AS ?sale) + schema:price ?sale } GRAPH ?productGraph { ?product schema:name ?productName } @@ -58,50 +67,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 ; @@ -122,7 +90,7 @@ WHERE { schema:provider ?supplier . } GRAPH ?supplierGraph { - ?supplier schema:name ?supplierName . + ?supplier schema:legalName ?supplierName . } } GROUP BY ?supplier ?supplierName @@ -132,13 +100,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/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/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 4bb26a9..6a041eb 100644 --- a/demo/northwind-traders/root.ttl +++ b/demo/northwind-traders/root.ttl @@ -12,35 +12,28 @@ <> 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 <#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> . - -<#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 <#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

-

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.

+
+

Executive dashboard

+

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 . <#sales-trend-block> a ldh:Object ; + dct:title "Monthly sales trend" ; rdf:value <#sales-trend> . <#sales-trend> a ldh:ResultSetChart ; @@ -60,16 +53,83 @@ WHERE { ?order a schema:Order ; schema:orderDate ?orderDate ; schema:orderedItem ?orderItem . + ?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 + # 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""" . + +<#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 ?price . - BIND(?quantity * ?price AS ?sale) + 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> . <#revenue-by-country> a ldh:ResultSetChart ; @@ -88,11 +148,9 @@ WHERE { GRAPH ?orderGraph { ?order schema:orderedItem ?orderItem ; schema:orderDelivery ?delivery . - ?orderItem schema:orderQuantity ?quantity ; - schema:price ?price . + ?orderItem schema:price ?sale . ?delivery schema:deliveryAddress ?address . ?address schema:addressCountry ?country . - BIND(?quantity * ?price AS ?sale) } } GROUP BY ?country @@ -100,22 +158,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 into detailed analytics by exploring the containers below. Each section provides comprehensive insights into products, orders, customers, employees, and more.

+
+

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)