@manta-eu/horns-client 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ {
2
+ "iri": "urn:manta-horns:horn:reference-supply-chain-360",
3
+ "slug": "supply-chain-360",
4
+ "title": "Supply Chain 360",
5
+ "summary": "Everything known about one counterparty: what is flagged, where it is exposed, who supplies it.",
6
+ "audience": "Supply chain",
7
+ "datastoreId": "salt-house",
8
+ "realm": "hiremanta-com",
9
+ "schemaVersion": 1,
10
+ "revision": 1,
11
+ "layout": {
12
+ "kind": "grid",
13
+ "columns": 12,
14
+ "items": [
15
+ {
16
+ "viewId": "entity",
17
+ "x": 0,
18
+ "y": 0,
19
+ "w": 3,
20
+ "h": 2
21
+ },
22
+ {
23
+ "viewId": "entity-stats",
24
+ "x": 3,
25
+ "y": 0,
26
+ "w": 9,
27
+ "h": 2
28
+ },
29
+ {
30
+ "viewId": "exposure-tiles",
31
+ "x": 0,
32
+ "y": 2,
33
+ "w": 4,
34
+ "h": 4
35
+ },
36
+ {
37
+ "viewId": "global-exposure",
38
+ "x": 4,
39
+ "y": 2,
40
+ "w": 5,
41
+ "h": 4
42
+ },
43
+ {
44
+ "viewId": "notifications",
45
+ "x": 9,
46
+ "y": 2,
47
+ "w": 3,
48
+ "h": 4
49
+ },
50
+ {
51
+ "viewId": "concentration-history",
52
+ "x": 0,
53
+ "y": 6,
54
+ "w": 9,
55
+ "h": 5
56
+ },
57
+ {
58
+ "viewId": "disruptions",
59
+ "x": 9,
60
+ "y": 6,
61
+ "w": 3,
62
+ "h": 5
63
+ },
64
+ {
65
+ "viewId": "top-suppliers",
66
+ "x": 0,
67
+ "y": 11,
68
+ "w": 12,
69
+ "h": 4
70
+ }
71
+ ]
72
+ },
73
+ "variables": [
74
+ {
75
+ "id": "entity",
76
+ "label": "Counterparty",
77
+ "type": "iri",
78
+ "urlParam": "entity"
79
+ },
80
+ {
81
+ "id": "category",
82
+ "label": "Alert kind",
83
+ "type": "string",
84
+ "urlParam": "kind"
85
+ },
86
+ {
87
+ "id": "country",
88
+ "label": "Country",
89
+ "type": "iri",
90
+ "urlParam": "country"
91
+ }
92
+ ],
93
+ "views": [
94
+ {
95
+ "id": "entity",
96
+ "title": "Who this is",
97
+ "binding": {
98
+ "kind": "select",
99
+ "sparql": "SELECT ?this ?name ?industry ?country ?entityType WHERE { BIND(?entity AS ?this) OPTIONAL { ?this schema:name ?name } OPTIONAL { ?this :factsetIndustry ?industry } OPTIONAL { ?this schema:addressCountry ?country } OPTIONAL { ?this :entityType ?entityType } }",
100
+ "params": ["entity"],
101
+ "limit": 1,
102
+ "derivations": [],
103
+ "columns": [
104
+ {
105
+ "name": "this",
106
+ "type": "iri"
107
+ },
108
+ {
109
+ "name": "name",
110
+ "type": "string"
111
+ },
112
+ {
113
+ "name": "industry",
114
+ "type": "string"
115
+ },
116
+ {
117
+ "name": "country",
118
+ "type": "iri"
119
+ },
120
+ {
121
+ "name": "entityType",
122
+ "type": "string"
123
+ }
124
+ ]
125
+ },
126
+ "render": {
127
+ "kind": "tsx",
128
+ "source": "import { Badge, Button, Separator } from \"@manta-eu/react\";\nimport { useHornFilter } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\nexport default function View({ rows, labels }) {\n const country = useHornFilter(\"country\");\n const one = rows[0];\n if (!one) return null;\n const named = one.country ? (labels[one.country] ?? \"Unknown country\") : undefined;\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 12 }}>\n <span style={{ fontSize: 20, lineHeight: 1.2, fontWeight: 600 }}>{one.name ?? \"Unnamed entity\"}</span>\n <span style={{ ...muted, fontSize: 14, lineHeight: 1.35 }}>{one.industry}</span>\n <Separator />\n <span style={{ display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: 8 }}>\n {named && country.live ? (\n <Button variant={country.is(one.country) ? \"primary\" : \"secondary\"} size=\"xs\" onClick={() => country.toggle(one.country)}>\n {named}\n </Button>\n ) : named ? (\n <Badge variant=\"secondary\">{named}</Badge>\n ) : null}\n {one.entityType ? <Badge variant=\"secondary\">{one.entityType}</Badge> : null}\n </span>\n </div>\n );\n}",
129
+ "reviewed": {
130
+ "hash": "sha256-tfqcvzciL4h4fl2Z7k58XJBW7uzMCdwTkzj1OHa7qzM",
131
+ "at": "2026-09-29T12:00:00.000Z"
132
+ }
133
+ }
134
+ },
135
+ {
136
+ "id": "notifications",
137
+ "title": "Supply chain notifications",
138
+ "binding": {
139
+ "kind": "select",
140
+ "sparql": "SELECT ?this ?headline ?source ?priority ?raised ?category WHERE { ?this :concerns ?entity ; :alertKind \"notification\" ; :headline ?headline ; :alertCategory ?category . OPTIONAL { ?this :alertSource ?source } OPTIONAL { ?this :alertPriority ?priority } OPTIONAL { ?this :raisedAt ?raised } } ORDER BY DESC(?raised)",
141
+ "params": ["entity", "category"],
142
+ "limit": 50,
143
+ "derivations": [],
144
+ "columns": [
145
+ {
146
+ "name": "this",
147
+ "type": "iri"
148
+ },
149
+ {
150
+ "name": "headline",
151
+ "type": "string"
152
+ },
153
+ {
154
+ "name": "source",
155
+ "type": "string"
156
+ },
157
+ {
158
+ "name": "priority",
159
+ "type": "string"
160
+ },
161
+ {
162
+ "name": "raised",
163
+ "type": "date"
164
+ },
165
+ {
166
+ "name": "category",
167
+ "type": "string"
168
+ }
169
+ ]
170
+ },
171
+ "render": {
172
+ "kind": "tsx",
173
+ "source": "import { Badge, Button, Link, ScrollArea, Separator, Status } from \"@manta-eu/react\";\nimport { Nothing, useHornFilter } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\n// Every row says where it came from and what it is worth. That is the property worth copying from Supply\n// Chain 360: an alert with no provenance is a rumour. The words are a person's, not the graph's.\n//\n// Where a row goes is `links`, which the host composes — never an href built from a column. A URL in the\n// data is a URL out of the app, and what a link should open is not the view's to decide.\nconst said = { red: \"Urgent\", amber: \"Needs attention\", green: \"For information\" };\nconst tone = (priority) =>\n priority === \"red\" ? \"error\" : priority === \"amber\" ? \"warning\" : priority === \"green\" ? \"success\" : \"neutral\";\n\nexport default function View({ rows, links }) {\n const category = useHornFilter(\"category\");\n const narrowed = typeof category.value === \"string\" ? category.value : undefined;\n return (\n <div style={{ display: \"flex\", height: \"100%\", minHeight: 0, flexDirection: \"column\", gap: 12 }}>\n {narrowed ? (\n <span style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: 8 }}>\n <span style={{ ...muted, fontSize: 12 }}>Only {narrowed}</span>\n <Button variant=\"ghost\" size=\"xs\" onClick={() => category.toggle(narrowed)}>\n Show all\n </Button>\n </span>\n ) : null}\n {rows.length === 0 ? (\n <Nothing title=\"Nothing raised\" hint=\"Rules raise these as they find work.\" />\n ) : (\n <ScrollArea style={{ minHeight: 0, flex: 1 }}>\n <ul style={{ display: \"flex\", flexDirection: \"column\", gap: 12 }}>\n {rows.map((row, index) => (\n <li key={row.this} style={{ display: \"flex\", flexDirection: \"column\", gap: 8 }}>\n {index === 0 ? null : <Separator />}\n <span style={{ fontSize: 14, lineHeight: 1.35 }}>{row.headline}</span>\n <span style={{ display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: 8 }}>\n {row.priority ? (\n <Status label={said[row.priority] ?? row.priority} variant={tone(row.priority)} />\n ) : null}\n {row.source ? <Badge variant=\"secondary\">{row.source}</Badge> : null}\n {links[row.this] ? (\n <Link href={links[row.this].href} variant=\"ghost\" size=\"xs\">\n {links[row.this].label}\n </Link>\n ) : null}\n </span>\n </li>\n ))}\n </ul>\n </ScrollArea>\n )}\n </div>\n );\n}",
174
+ "reviewed": {
175
+ "hash": "sha256-XA8o7l3yR7VosLu3uqbXQk3cOWKUJUP0TcQzi15v9Sc",
176
+ "at": "2026-09-29T12:00:00.000Z"
177
+ }
178
+ }
179
+ },
180
+ {
181
+ "id": "disruptions",
182
+ "title": "Disruptions",
183
+ "binding": {
184
+ "kind": "select",
185
+ "sparql": "SELECT ?this ?headline ?source ?priority ?raised ?category WHERE { ?this :concerns ?entity ; :alertKind \"disruption\" ; :headline ?headline ; :alertCategory ?category . OPTIONAL { ?this :alertSource ?source } OPTIONAL { ?this :alertPriority ?priority } OPTIONAL { ?this :raisedAt ?raised } } ORDER BY DESC(?raised)",
186
+ "params": ["entity", "category"],
187
+ "limit": 50,
188
+ "derivations": [],
189
+ "columns": [
190
+ {
191
+ "name": "this",
192
+ "type": "iri"
193
+ },
194
+ {
195
+ "name": "headline",
196
+ "type": "string"
197
+ },
198
+ {
199
+ "name": "source",
200
+ "type": "string"
201
+ },
202
+ {
203
+ "name": "priority",
204
+ "type": "string"
205
+ },
206
+ {
207
+ "name": "raised",
208
+ "type": "date"
209
+ },
210
+ {
211
+ "name": "category",
212
+ "type": "string"
213
+ }
214
+ ]
215
+ },
216
+ "render": {
217
+ "kind": "tsx",
218
+ "source": "import { Badge, Button, Link, ScrollArea, Separator, Status } from \"@manta-eu/react\";\nimport { Nothing, useHornFilter } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\n// Every row says where it came from and what it is worth. That is the property worth copying from Supply\n// Chain 360: an alert with no provenance is a rumour. The words are a person's, not the graph's.\n//\n// Where a row goes is `links`, which the host composes — never an href built from a column. A URL in the\n// data is a URL out of the app, and what a link should open is not the view's to decide.\nconst said = { red: \"Urgent\", amber: \"Needs attention\", green: \"For information\" };\nconst tone = (priority) =>\n priority === \"red\" ? \"error\" : priority === \"amber\" ? \"warning\" : priority === \"green\" ? \"success\" : \"neutral\";\n\nexport default function View({ rows, links }) {\n const category = useHornFilter(\"category\");\n const narrowed = typeof category.value === \"string\" ? category.value : undefined;\n return (\n <div style={{ display: \"flex\", height: \"100%\", minHeight: 0, flexDirection: \"column\", gap: 12 }}>\n {narrowed ? (\n <span style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: 8 }}>\n <span style={{ ...muted, fontSize: 12 }}>Only {narrowed}</span>\n <Button variant=\"ghost\" size=\"xs\" onClick={() => category.toggle(narrowed)}>\n Show all\n </Button>\n </span>\n ) : null}\n {rows.length === 0 ? (\n <Nothing title=\"Nothing raised\" hint=\"Events affecting this entity appear here.\" />\n ) : (\n <ScrollArea style={{ minHeight: 0, flex: 1 }}>\n <ul style={{ display: \"flex\", flexDirection: \"column\", gap: 12 }}>\n {rows.map((row, index) => (\n <li key={row.this} style={{ display: \"flex\", flexDirection: \"column\", gap: 8 }}>\n {index === 0 ? null : <Separator />}\n <span style={{ fontSize: 14, lineHeight: 1.35 }}>{row.headline}</span>\n <span style={{ display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: 8 }}>\n {row.priority ? (\n <Status label={said[row.priority] ?? row.priority} variant={tone(row.priority)} />\n ) : null}\n {row.source ? <Badge variant=\"secondary\">{row.source}</Badge> : null}\n {links[row.this] ? (\n <Link href={links[row.this].href} variant=\"ghost\" size=\"xs\">\n {links[row.this].label}\n </Link>\n ) : null}\n </span>\n </li>\n ))}\n </ul>\n </ScrollArea>\n )}\n </div>\n );\n}",
219
+ "reviewed": {
220
+ "hash": "sha256-Vg6rer0ebCYI9tXTrGxWClmK8Wvr39W2MN0XSCE3wOA",
221
+ "at": "2026-09-29T12:00:00.000Z"
222
+ }
223
+ }
224
+ },
225
+ {
226
+ "id": "entity-stats",
227
+ "title": "Entity stats",
228
+ "binding": {
229
+ "kind": "select",
230
+ "sparql": "SELECT (COUNT(DISTINCT ?order) AS ?outgoing) (SUM(?amount) AS ?outgoingValue) (COUNT(DISTINCT ?supplier) AS ?suppliers) (COUNT(DISTINCT ?customer) AS ?customers) WHERE { OPTIONAL { ?entity :placedOrder ?order . OPTIONAL { ?order schema:totalPrice ?amount } } OPTIONAL { ?entity :tier1Supplier ?supplier } OPTIONAL { ?entity :tier1Customer ?customer } }",
231
+ "params": ["entity"],
232
+ "limit": 1,
233
+ "derivations": [],
234
+ "columns": [
235
+ {
236
+ "name": "outgoing",
237
+ "type": "number"
238
+ },
239
+ {
240
+ "name": "outgoingValue",
241
+ "type": "number"
242
+ },
243
+ {
244
+ "name": "suppliers",
245
+ "type": "number"
246
+ },
247
+ {
248
+ "name": "customers",
249
+ "type": "number"
250
+ }
251
+ ]
252
+ },
253
+ "render": {
254
+ "kind": "tsx",
255
+ "source": "import { Separator } from \"@manta-eu/react\";\nimport { WhyThis } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\nconst money = (value) =>\n typeof value === \"number\"\n ? `$${value.toLocaleString(\"en-US\", { notation: \"compact\", maximumFractionDigits: 2 })}`\n : \"—\";\nconst count = (value) => (typeof value === \"number\" ? value.toLocaleString(\"en-US\") : \"—\");\n\n/** Each figure is counted, not recorded, so each one can say what it counted. */\nfunction countedFrom(subject, predicate, over, value) {\n const claim = `${predicate}[<${subject}>, ${value}]`;\n return {\n [claim]: {\n fact: claim,\n type: \"derived\",\n \"rule-instances-complete\": true,\n \"rule-instances\": [\n {\n rule: `${predicate}[?entity, ?total] :- AGGREGATE(${over}[?entity, ?one] ON ?entity BIND COUNT(?one) AS ?total) .`,\n \"grounded-rule\": `${claim} :- AGGREGATE(${over}[<${subject}>, ?one] ON <${subject}> BIND COUNT(?one) AS ${value}) .`,\n \"body-facts\": [],\n },\n ],\n },\n };\n}\n\nexport default function View({ rows }) {\n const one = rows[0] ?? {};\n const subject = one.this ?? \"\";\n const stats = [\n { id: \":outgoingTransactions\", over: \":placedOrder\", raw: one.outgoing, value: count(one.outgoing), label: \"Total outgoing transactions\" },\n { id: \":outgoingValue\", over: \":placedOrder\", raw: one.outgoingValue, value: money(one.outgoingValue), label: \"Total outgoing transaction value\" },\n { id: \":tier1SupplierCount\", over: \":tier1Supplier\", raw: one.suppliers, value: count(one.suppliers), label: \"Tier 1 suppliers\" },\n { id: \":tier1CustomerCount\", over: \":tier1Customer\", raw: one.customers, value: count(one.customers), label: \"Tier 1 customers\" },\n ];\n return (\n <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(4, minmax(0, 1fr))\", alignItems: \"center\" }}>\n {stats.map((stat, index) => (\n <span key={stat.label} style={{ display: \"flex\", minWidth: 0, alignItems: \"center\", gap: 20 }}>\n {index === 0 ? null : <Separator orientation=\"vertical\" style={{ height: 40 }} />}\n <span style={{ display: \"flex\", minWidth: 0, flexDirection: \"column\", gap: 4 }}>\n <span style={{ fontSize: 20, lineHeight: 1, fontWeight: 600, fontVariantNumeric: \"tabular-nums\" }}>\n <WhyThis\n subject={subject}\n predicate={stat.id}\n object={String(stat.raw ?? \"\")}\n title={stat.label}\n data={countedFrom(subject, stat.id, stat.over, stat.raw ?? 0)}\n >\n {stat.value}\n </WhyThis>\n </span>\n <span style={{ ...muted, fontSize: 12, lineHeight: 1.35 }}>{stat.label}</span>\n </span>\n </span>\n ))}\n </div>\n );\n}",
256
+ "reviewed": {
257
+ "hash": "sha256-pS3MIel4V5h24O74GydrKs2E8DQDx_YaBvKMU_ImE48",
258
+ "at": "2026-09-29T12:00:00.000Z"
259
+ }
260
+ }
261
+ },
262
+ {
263
+ "id": "exposure-tiles",
264
+ "title": "What is flagged",
265
+ "binding": {
266
+ "kind": "select",
267
+ "sparql": "SELECT ?kind (COUNT(DISTINCT ?alert) AS ?total) (SAMPLE(?note) AS ?qualifier) (SAMPLE(?level) AS ?severity) WHERE { ?alert :concerns ?entity ; :alertKind ?kind . OPTIONAL { ?alert :qualifier ?note } OPTIONAL { ?alert :severity ?level } } GROUP BY ?kind",
268
+ "params": ["entity"],
269
+ "limit": 12,
270
+ "derivations": [],
271
+ "columns": [
272
+ {
273
+ "name": "kind",
274
+ "type": "string"
275
+ },
276
+ {
277
+ "name": "total",
278
+ "type": "number"
279
+ },
280
+ {
281
+ "name": "qualifier",
282
+ "type": "string"
283
+ },
284
+ {
285
+ "name": "severity",
286
+ "type": "string"
287
+ }
288
+ ]
289
+ },
290
+ "render": {
291
+ "kind": "tsx",
292
+ "source": "import { Button, Card, CardContent, Status } from \"@manta-eu/react\";\nimport { Nothing, useHornFilter } from \"@manta-eu/horns-kit/view\";\n\n// Each tile is a filter on the feed beside it, which is the property worth copying: a number you cannot\n// press is a statistic, and a number you can is a way in. The tile and the feed are not wired to each\n// other — they both answer to the Horn's `category`.\nconst tone = (severity) => (severity === \"high\" ? \"error\" : severity === \"medium\" ? \"warning\" : \"neutral\");\n\nexport default function View({ rows }) {\n const category = useHornFilter(\"category\");\n if (rows.length === 0) {\n return <Nothing title=\"Nothing flagged\" hint=\"Alerts about this entity appear here as rules find them.\" />;\n }\n return (\n <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(2, minmax(0, 1fr))\", gap: 12 }}>\n {rows.map((row) => (\n <Card\n key={row.kind}\n data-selected={category.is(row.kind) ? \"true\" : undefined}\n style={{ outline: category.is(row.kind) ? \"2px solid currentColor\" : undefined }}\n >\n <CardContent style={{ padding: 0 }}>\n <Button\n variant=\"ghost\"\n disabled={!category.live}\n onClick={() => category.toggle(row.kind)}\n style={{ height: \"auto\", width: \"100%\", flexDirection: \"column\", alignItems: \"flex-start\", gap: 8, padding: \"12px 16px\", textAlign: \"left\" }}\n >\n <span style={{ display: \"flex\", alignItems: \"baseline\", gap: 8 }}>\n <span style={{ fontSize: 24, lineHeight: 1, fontWeight: 600, fontVariantNumeric: \"tabular-nums\" }}>{row.total ?? 0}</span>\n <span style={{ fontSize: 14, lineHeight: 1.35 }}>{row.kind}</span>\n </span>\n {row.qualifier ? <Status label={row.qualifier} variant={tone(row.severity)} /> : null}\n </Button>\n </CardContent>\n </Card>\n ))}\n </div>\n );\n}",
293
+ "reviewed": {
294
+ "hash": "sha256-aq1E3_aTrfvoWTMfy3XniJEkX-cSnn7YCOxqZaotqg4",
295
+ "at": "2026-09-29T12:00:00.000Z"
296
+ }
297
+ }
298
+ },
299
+ {
300
+ "id": "global-exposure",
301
+ "title": "Global exposure",
302
+ "binding": {
303
+ "kind": "select",
304
+ "sparql": "SELECT ?this ?buyerConcentration ?supplierConcentration ?disruptions WHERE { ?entity :exposedTo ?exposure . ?exposure schema:addressCountry ?this . OPTIONAL { ?exposure :buyerConcentration ?buyerConcentration } OPTIONAL { ?exposure :supplierConcentration ?supplierConcentration } OPTIONAL { ?exposure :activeDisruptions ?disruptions } } ORDER BY DESC(?supplierConcentration)",
305
+ "params": ["entity"],
306
+ "limit": 40,
307
+ "derivations": [],
308
+ "columns": [
309
+ {
310
+ "name": "this",
311
+ "type": "iri"
312
+ },
313
+ {
314
+ "name": "buyerConcentration",
315
+ "type": "number"
316
+ },
317
+ {
318
+ "name": "supplierConcentration",
319
+ "type": "number"
320
+ },
321
+ {
322
+ "name": "disruptions",
323
+ "type": "number"
324
+ }
325
+ ]
326
+ },
327
+ "render": {
328
+ "kind": "tsx",
329
+ "source": "import { Button, Status } from \"@manta-eu/react\";\nimport { Nothing, useHornFilter } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\n// The gap, drawn honestly. Supply Chain 360 puts two scales on one choropleth with per-layer toggles and\n// disruption markers over both; nothing on the view surface draws a map, so the same numbers are ranked —\n// and pressing a country narrows the suppliers beneath it, which the map also does.\nfunction Meter({ label, value }) {\n return (\n <span style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span style={{ ...muted, width: 56, flexShrink: 0, fontSize: 12 }}>{label}</span>\n <span style={{ position: \"relative\", height: 6, minWidth: 0, flex: 1, overflow: \"hidden\" }}>\n <span style={{ position: \"absolute\", inset: 0, background: \"currentColor\", opacity: 0.15 }} />\n <span\n style={{\n position: \"absolute\",\n left: 0,\n top: 0,\n bottom: 0,\n background: \"currentColor\",\n width: `${Math.min(100, Math.max(0, value))}%`,\n }}\n />\n </span>\n <span style={{ width: 28, flexShrink: 0, textAlign: \"right\", fontSize: 12, fontVariantNumeric: \"tabular-nums\" }}>{Math.round(value)}</span>\n </span>\n );\n}\n\nexport default function View({ rows, labels }) {\n const country = useHornFilter(\"country\");\n if (rows.length === 0) {\n return <Nothing title=\"No exposure recorded\" hint=\"Countries appear here once transactions name them.\" />;\n }\n return (\n <div style={{ display: \"flex\", flexDirection: \"column\", gap: 4 }}>\n {rows.map((row) => (\n <Button\n key={row.this}\n variant=\"ghost\"\n disabled={!country.live}\n onClick={() => country.toggle(row.this)}\n style={{\n height: \"auto\",\n width: \"100%\",\n flexDirection: \"column\",\n alignItems: \"stretch\",\n gap: 6,\n padding: \"6px 8px\",\n outline: country.is(row.this) ? \"2px solid currentColor\" : undefined,\n }}\n >\n <span style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: 8 }}>\n <span style={{ fontSize: 14 }}>{labels[row.this] ?? \"Unknown country\"}</span>\n {row.disruptions ? (\n <Status\n label={`${row.disruptions} ${row.disruptions === 1 ? \"disruption\" : \"disruptions\"}`}\n variant=\"warning\"\n />\n ) : null}\n </span>\n <Meter label=\"Supplier\" value={row.supplierConcentration ?? 0} />\n <Meter label=\"Buyer\" value={row.buyerConcentration ?? 0} />\n </Button>\n ))}\n </div>\n );\n}",
330
+ "reviewed": {
331
+ "hash": "sha256-sElmawlSY1Z1RnHzK8Efe_4fTf-evXnZYoO08fMfZsA",
332
+ "at": "2026-09-29T12:00:00.000Z"
333
+ }
334
+ }
335
+ },
336
+ {
337
+ "id": "concentration-history",
338
+ "title": "Concentration history",
339
+ "binding": {
340
+ "kind": "select",
341
+ "sparql": "SELECT ?month (AVG(?score) AS ?concentration) WHERE { ?entity :tier1Supplier ?supplier . ?supplier :concentrationReading ?reading . ?reading :readingMonth ?month ; :concentrationScore ?score } GROUP BY ?month ORDER BY ?month",
342
+ "params": ["entity"],
343
+ "limit": 60,
344
+ "derivations": [],
345
+ "columns": [
346
+ {
347
+ "name": "month",
348
+ "type": "string"
349
+ },
350
+ {
351
+ "name": "concentration",
352
+ "type": "number"
353
+ }
354
+ ]
355
+ },
356
+ "render": {
357
+ "kind": "tsx",
358
+ "source": "import { AreaChart } from \"@manta-eu/react/chart\";\nimport { Nothing } from \"@manta-eu/horns-kit/view\";\n\nconst muted = { opacity: 0.7 };\n\n// The annotations are the half already paid for: the SDK accepts them and the view surface did not list\n// them, so the Studio had never written one. The band and the benchmark are what let the picture answer\n// \"is this bad\" without a legend.\nexport default function View({ rows }) {\n if (rows.length === 0) {\n return <Nothing title=\"No readings yet\" hint=\"Concentration is scored monthly once orders are loaded.\" />;\n }\n return (\n <div style={{ display: \"flex\", height: \"100%\", minHeight: 0, flexDirection: \"column\", gap: 8 }}>\n <span style={{ ...muted, fontSize: 14 }}>\n Higher means more of this entity's supply sits with fewer suppliers.\n </span>\n <span style={{ minHeight: 0, flex: 1 }}>\n <AreaChart\n data={{\n type: \"area\",\n data: rows.map((row) => ({ x: row.month, y: row.concentration ?? 0 })),\n yAxisLabel: \"Concentration score\",\n xAxisLabel: \"Month\",\n annotations: [\n { kind: \"reference-area\", axis: \"y\", from: 70, to: 100, label: \"Concentrated\", tone: \"danger\" },\n { kind: \"reference-line\", axis: \"y\", value: 70, label: \"Benchmark 4\", tone: \"warning\" },\n { kind: \"stat-line\", stat: \"average\", axis: \"y\", label: \"Average\", tone: \"neutral\" },\n ],\n }}\n />\n </span>\n </div>\n );\n}",
359
+ "reviewed": {
360
+ "hash": "sha256-APKf-WP6MesibH3o6eyDfbnHgaPzunvv9ACy5Dc6Qpw",
361
+ "at": "2026-09-29T12:00:00.000Z"
362
+ }
363
+ }
364
+ },
365
+ {
366
+ "id": "top-suppliers",
367
+ "title": "Top suppliers",
368
+ "binding": {
369
+ "kind": "select",
370
+ "sparql": "SELECT ?this ?concentration ?yearOnYear ?country WHERE { ?entity :tier1Supplier ?this . ?this schema:addressCountry ?country . OPTIONAL { ?this :ltmConcentration ?concentration } OPTIONAL { ?this :concentrationYoY ?yearOnYear } } ORDER BY DESC(?concentration)",
371
+ "params": ["entity", "country"],
372
+ "limit": 100,
373
+ "derivations": [],
374
+ "columns": [
375
+ {
376
+ "name": "this",
377
+ "type": "iri"
378
+ },
379
+ {
380
+ "name": "concentration",
381
+ "type": "number"
382
+ },
383
+ {
384
+ "name": "yearOnYear",
385
+ "type": "number"
386
+ },
387
+ {
388
+ "name": "country",
389
+ "type": "iri"
390
+ }
391
+ ]
392
+ },
393
+ "render": {
394
+ "kind": "tsx",
395
+ "source": "import {\n Badge,\n Button,\n Link,\n ScrollArea,\n Status,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"@manta-eu/react\";\nimport { Nothing, WhyThis, useHornFilter } from \"@manta-eu/horns-kit/view\";\n\n// The picture above and the numbers here say the same thing; neither does the other's job. The judgement is\n// already made in the cell, and the cell says why it was made.\nconst tone = (value) => (value >= 50 ? \"error\" : value >= 10 ? \"warning\" : \"success\");\nconst signed = (value) => (typeof value === \"number\" ? `${value > 0 ? \"+\" : \"\"}${value.toFixed(2)}` : \"—\");\n\nconst concentrated = \":concentrated\";\nconst ltm = \":ltmConcentration\";\n\n/** The derivation behind one supplier's score, in the shape RDFox answers an explain request with. */\nfunction explanationOf(supplier, score) {\n const claim = `${concentrated}[<${supplier}>, true]`;\n const evidence = `${ltm}[<${supplier}>, ${score}]`;\n return {\n [claim]: {\n fact: claim,\n type: \"derived\",\n \"rule-instances-complete\": true,\n \"rule-instances\": [\n {\n rule: `${concentrated}[?supplier, true] :- ${ltm}[?supplier, ?score], FILTER(?score > 50) .`,\n \"grounded-rule\": `${claim} :- ${evidence}, FILTER(${score} > 50) .`,\n \"body-facts\": [evidence],\n },\n ],\n },\n [evidence]: { fact: evidence, type: \"explicit\" },\n };\n}\n\nexport default function View({ rows, labels, links }) {\n const country = useHornFilter(\"country\");\n if (rows.length === 0) {\n return (\n <Nothing\n title=\"No suppliers here\"\n hint={country.live ? \"Nothing matches the country this is narrowed to.\" : \"Suppliers appear once orders name them.\"}\n />\n );\n }\n return (\n <ScrollArea style={{ height: \"100%\" }}>\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Supplier</TableHead>\n <TableHead>LTM concentration</TableHead>\n <TableHead style={{ textAlign: \"right\" }}>YoY change</TableHead>\n <TableHead>Country</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.this}>\n <TableCell>\n {links[row.this] ? (\n <Link href={links[row.this].href} variant=\"ghost\" size=\"xs\">\n {labels[row.this] ?? \"Unnamed supplier\"}\n </Link>\n ) : (\n (labels[row.this] ?? \"Unnamed supplier\")\n )}\n </TableCell>\n <TableCell>\n <WhyThis\n subject={row.this}\n predicate={concentrated}\n object=\"true\"\n title=\"Why this counts as concentrated\"\n data={explanationOf(row.this, row.concentration ?? 0)}\n >\n <Status label={(row.concentration ?? 0).toFixed(2)} variant={tone(row.concentration ?? 0)} />\n </WhyThis>\n </TableCell>\n <TableCell style={{ textAlign: \"right\", fontVariantNumeric: \"tabular-nums\" }}>{signed(row.yearOnYear)}</TableCell>\n <TableCell>\n {row.country && country.live ? (\n <Button\n variant={country.is(row.country) ? \"primary\" : \"secondary\"}\n size=\"xs\"\n onClick={() => country.toggle(row.country)}\n >\n {labels[row.country] ?? \"Unknown\"}\n </Button>\n ) : row.country ? (\n <Badge variant=\"secondary\">{labels[row.country] ?? \"Unknown\"}</Badge>\n ) : null}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </ScrollArea>\n );\n}",
396
+ "reviewed": {
397
+ "hash": "sha256-LKFpSDObwpNFQVsaQtPC0c5KFFZODUM3OecZv68wTis",
398
+ "at": "2026-09-29T12:00:00.000Z"
399
+ }
400
+ }
401
+ }
402
+ ],
403
+ "derivations": [],
404
+ "actions": [],
405
+ "dependencies": [],
406
+ "lock": {
407
+ "lockVersion": 1,
408
+ "entries": []
409
+ },
410
+ "runtime": {
411
+ "kit": "0.0.0"
412
+ }
413
+ }