@datashow/super-visual-cli 0.0.0-stage → 0.1.0-rc.1
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.
- package/bin/supervisual.mjs +9 -0
- package/package.json +45 -4
- package/src/fabric-client.mjs +101 -0
- package/src/main.mjs +729 -0
- package/src/project-target.mjs +80 -0
- package/src/project.mjs +307 -0
- package/src/semantic-preview.mjs +528 -0
- package/templates/blank/README.md +26 -0
- package/templates/blank/contracts/page-set.yaml +78 -0
- package/templates/blank/contracts/report-source.json +30 -0
- package/templates/blank/contracts/semantic-model.yaml +39 -0
- package/templates/blank/contracts/widget-broker.json +33 -0
- package/templates/blank/deployment/CERTIFICATION.md +18 -0
- package/templates/blank/package.json +15 -0
- package/templates/blank/source/report-design.less +20 -0
- package/templates/blank/source/reportApplication.tsx +29 -0
- package/templates/blank/supervisual.yaml +24 -0
- package/templates/hello-world/README.md +29 -0
- package/templates/hello-world/contracts/page-set.yaml +74 -0
- package/templates/hello-world/contracts/report-source.json +45 -0
- package/templates/hello-world/contracts/semantic-model.yaml +32 -0
- package/templates/hello-world/contracts/widget-broker.json +37 -0
- package/templates/hello-world/deployment/CERTIFICATION.md +17 -0
- package/templates/hello-world/package.json +15 -0
- package/templates/hello-world/source/report-design.less +35 -0
- package/templates/hello-world/source/reportApplication.tsx +69 -0
- package/templates/hello-world/supervisual.yaml +24 -0
- package/templates/semantic-preview/index.html +14 -0
- package/templates/semantic-preview/src/main.tsx +172 -0
- package/README.md +0 -3
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { buildSolution, loadProject, validateCompilation } from "@datashow/super-visual-compiler";
|
|
8
|
+
import { checkSemanticCompatibility, inspectSemanticModel } from "@datashow/super-visual-fabric";
|
|
9
|
+
import { createServer } from "vite";
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const previewTemplate = path.join(packageRoot, "templates", "semantic-preview");
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const BROKER_FIELDS = ["WidgetId", "AxisKey", "Label", "Sort", "SeriesKey", "SeriesLabel", "WidgetValue", "BrokerContext"];
|
|
15
|
+
const BROKER_COLUMNS = BROKER_FIELDS.slice(0, 6);
|
|
16
|
+
const BROKER_MEASURES = ["Widget Value", "Broker Context"];
|
|
17
|
+
const PREVIEW_RESPONSE_HEADERS = {
|
|
18
|
+
"Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
19
|
+
"Referrer-Policy": "no-referrer",
|
|
20
|
+
"X-Content-Type-Options": "nosniff",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function daxString(value) {
|
|
24
|
+
return `"${String(value).replaceAll('"', '""')}"`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function daxReference(reference) {
|
|
28
|
+
const separator = typeof reference === "string" && reference.endsWith("]")
|
|
29
|
+
? reference.lastIndexOf("[", reference.length - 2)
|
|
30
|
+
: -1;
|
|
31
|
+
if (separator <= 0 || separator === reference.length - 2) throw new Error(`Semantic filter target must use Table[Column] notation: ${reference}`);
|
|
32
|
+
const table = reference.slice(0, separator);
|
|
33
|
+
const column = reference.slice(separator + 1, -1);
|
|
34
|
+
const escapedColumn = column.replaceAll("]", "]]");
|
|
35
|
+
return { table, column, dax: `'${table.replaceAll("'", "''")}'[${escapedColumn}]` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function semanticColumn(semanticModel, reference) {
|
|
39
|
+
const target = daxReference(reference);
|
|
40
|
+
const table = semanticModel.tables.find((candidate) => candidate.name === target.table);
|
|
41
|
+
const column = table?.columns?.find((candidate) => candidate.name === target.column);
|
|
42
|
+
if (!column) throw new Error(`Unknown semantic column ${reference}`);
|
|
43
|
+
return { ...target, dataType: column.dataType };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function semanticMeasure(semanticModel, reference) {
|
|
47
|
+
if (typeof reference !== "string" || !reference.endsWith("]")) throw new Error(`Semantic measure must use [Measure] or Table[Measure] notation: ${reference}`);
|
|
48
|
+
const separator = reference.lastIndexOf("[", reference.length - 2);
|
|
49
|
+
if (separator > 0) {
|
|
50
|
+
const tableName = reference.slice(0, separator);
|
|
51
|
+
const measureName = reference.slice(separator + 1, -1);
|
|
52
|
+
const table = semanticModel.tables.find((candidate) => candidate.name === tableName);
|
|
53
|
+
if (!(table?.measures ?? []).some((candidate) => candidate.name === measureName)) throw new Error(`Unknown semantic measure ${reference}`);
|
|
54
|
+
return { table: tableName, name: measureName, dax: `'${tableName.replaceAll("'", "''")}'[${measureName.replaceAll("]", "]]" )}]` };
|
|
55
|
+
}
|
|
56
|
+
if (separator === 0) {
|
|
57
|
+
const measureName = reference.slice(1, -1);
|
|
58
|
+
const matches = semanticModel.tables.filter((table) => (table.measures ?? []).some((candidate) => candidate.name === measureName));
|
|
59
|
+
if (matches.length !== 1) throw new Error(`Semantic measure ${reference} must resolve uniquely`);
|
|
60
|
+
return { table: matches[0].name, name: measureName, dax: `[${measureName.replaceAll("]", "]]" )}]` };
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Semantic measure must use [Measure] or Table[Measure] notation: ${reference}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function axisKeyExpression(column) {
|
|
66
|
+
if (column.dataType === "String") return column.dax;
|
|
67
|
+
if (column.dataType === "DateTime") return `FORMAT(${column.dax}, "yyyy-MM-dd")`;
|
|
68
|
+
return `FORMAT(${column.dax}, "0.############")`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sortExpression(semanticModel, axis) {
|
|
72
|
+
const table = semanticModel.tables.find((candidate) => candidate.name === axis.table);
|
|
73
|
+
const column = table?.columns?.find((candidate) => candidate.name === axis.column);
|
|
74
|
+
const sort = column?.sortByColumn ? semanticColumn(semanticModel, `${axis.table}[${column.sortByColumn}]`) : axis;
|
|
75
|
+
if (sort.dataType === "DateTime") return `VALUE(FORMAT(${sort.dax}, "yyyymmdd"))`;
|
|
76
|
+
if (["Int64", "Decimal", "Double"].includes(sort.dataType)) return `INT(${sort.dax})`;
|
|
77
|
+
return "0";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function measureLabel(reference) {
|
|
81
|
+
const separator = reference.lastIndexOf("[", reference.length - 2);
|
|
82
|
+
return reference.slice(separator + 1, -1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function daxValue(value, dataType) {
|
|
86
|
+
if (["Int64", "Decimal", "Double"].includes(dataType)) {
|
|
87
|
+
const numeric = Number(value);
|
|
88
|
+
if (!Number.isFinite(numeric)) throw new Error(`Selection value is not valid for ${dataType}`);
|
|
89
|
+
return String(numeric);
|
|
90
|
+
}
|
|
91
|
+
if (dataType === "Boolean") {
|
|
92
|
+
if (value === true || String(value).toLowerCase() === "true") return "TRUE()";
|
|
93
|
+
if (value === false || String(value).toLowerCase() === "false") return "FALSE()";
|
|
94
|
+
throw new Error("Selection value is not valid for Boolean");
|
|
95
|
+
}
|
|
96
|
+
if (dataType === "DateTime") {
|
|
97
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value));
|
|
98
|
+
if (!match) throw new Error("Selection value must use YYYY-MM-DD for DateTime");
|
|
99
|
+
return `DATE(${Number(match[1])}, ${Number(match[2])}, ${Number(match[3])})`;
|
|
100
|
+
}
|
|
101
|
+
return daxString(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function filterExpression(semanticModel, reference, values) {
|
|
105
|
+
const target = semanticColumn(semanticModel, reference);
|
|
106
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
107
|
+
return `TREATAS({ ${values.map((value) => daxValue(value, target.dataType)).join(", ")} }, ${target.dax})`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parameterValues(parameter, value) {
|
|
111
|
+
const values = Array.isArray(value) ? value : [value];
|
|
112
|
+
return values.flatMap((candidate) => {
|
|
113
|
+
if (candidate === null || candidate === undefined || candidate === "") return [];
|
|
114
|
+
const resolved = parameter.values?.[String(candidate)] ?? candidate;
|
|
115
|
+
return [resolved];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateQueryRequest(request, loaded) {
|
|
120
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) throw new Error("Preview query body must be an object");
|
|
121
|
+
const unexpected = Object.keys(request).filter((key) => !["widgets", "selections", "parameters"].includes(key));
|
|
122
|
+
if (unexpected.length) throw new Error(`Preview query contains unsupported fields: ${unexpected.join(", ")}`);
|
|
123
|
+
const widgetById = new Map(loaded.broker.widgets.map((widget) => [widget.id, widget]));
|
|
124
|
+
const knownWidgets = new Set(widgetById.keys());
|
|
125
|
+
const widgets = Array.isArray(request.widgets) ? [...new Set(request.widgets)] : [];
|
|
126
|
+
if (!widgets.length || widgets.some((widget) => typeof widget !== "string" || !knownWidgets.has(widget))) throw new Error("Preview query contains an unknown or empty widget selection");
|
|
127
|
+
const selections = request.selections && typeof request.selections === "object" && !Array.isArray(request.selections) ? request.selections : {};
|
|
128
|
+
for (const [widget, value] of Object.entries(selections)) {
|
|
129
|
+
if (!knownWidgets.has(widget)) throw new Error(`Preview selection references unknown widget ${widget}`);
|
|
130
|
+
if (widgetById.get(widget).selectable !== true) throw new Error(`Preview selection references non-selectable widget ${widget}`);
|
|
131
|
+
if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`Preview selection for ${widget} must be a scalar value`);
|
|
132
|
+
}
|
|
133
|
+
const parameters = request.parameters && typeof request.parameters === "object" && !Array.isArray(request.parameters) ? request.parameters : {};
|
|
134
|
+
const knownParameters = new Set((loaded.reportSource.adapter?.parameters ?? []).map((parameter) => parameter.name));
|
|
135
|
+
for (const [name, value] of Object.entries(parameters)) {
|
|
136
|
+
if (!knownParameters.has(name)) throw new Error(`Preview request references unknown parameter ${name}`);
|
|
137
|
+
const values = Array.isArray(value) ? value : [value];
|
|
138
|
+
if (values.some((candidate) => !["string", "number", "boolean"].includes(typeof candidate))) throw new Error(`Preview parameter ${name} must contain only scalar values`);
|
|
139
|
+
}
|
|
140
|
+
return { widgets, selections, parameters };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildManagedSemanticQuery({ broker, semanticModel, reportSource }, request) {
|
|
144
|
+
const loaded = { broker, semanticModel, reportSource };
|
|
145
|
+
const { widgets, selections, parameters } = validateQueryRequest(request, loaded);
|
|
146
|
+
const widgetById = new Map(broker.widgets.map((widget) => [widget.id, widget]));
|
|
147
|
+
const filters = [
|
|
148
|
+
`TREATAS({ ${widgets.map(daxString).join(", ")} }, 'Widget Axis'[WidgetId])`,
|
|
149
|
+
...Object.entries(selections).map(([widget, value]) => filterExpression(semanticModel, widgetById.get(widget).axis, [value])),
|
|
150
|
+
...Object.entries(parameters).map(([name, value]) => {
|
|
151
|
+
const parameter = reportSource.adapter.parameters.find((candidate) => candidate.name === name);
|
|
152
|
+
return filterExpression(semanticModel, parameter.target, parameterValues(parameter, value));
|
|
153
|
+
}),
|
|
154
|
+
].filter(Boolean);
|
|
155
|
+
return `EVALUATE
|
|
156
|
+
VAR __BrokerRows =
|
|
157
|
+
CALCULATETABLE(
|
|
158
|
+
SELECTCOLUMNS(
|
|
159
|
+
'Widget Axis',
|
|
160
|
+
"WidgetId", 'Widget Axis'[WidgetId],
|
|
161
|
+
"AxisKey", 'Widget Axis'[AxisKey],
|
|
162
|
+
"Label", 'Widget Axis'[Label],
|
|
163
|
+
"Sort", 'Widget Axis'[Sort],
|
|
164
|
+
"SeriesKey", 'Widget Axis'[SeriesKey],
|
|
165
|
+
"SeriesLabel", 'Widget Axis'[SeriesLabel],
|
|
166
|
+
"WidgetValue", [Widget Value],
|
|
167
|
+
"BrokerContext", [Broker Context]
|
|
168
|
+
),
|
|
169
|
+
${filters.join(",\n ")}
|
|
170
|
+
)
|
|
171
|
+
RETURN
|
|
172
|
+
FILTER(__BrokerRows, NOT ISBLANK([WidgetValue]))
|
|
173
|
+
ORDER BY [WidgetId], [Sort], [AxisKey], [SeriesKey]`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function buildSemanticPreviewQuery({ broker, semanticModel, reportSource }, request) {
|
|
177
|
+
const loaded = { broker, semanticModel, reportSource };
|
|
178
|
+
const { widgets, selections, parameters } = validateQueryRequest(request, loaded);
|
|
179
|
+
const widgetById = new Map(broker.widgets.map((widget) => [widget.id, widget]));
|
|
180
|
+
const parameterFilters = Object.entries(parameters).map(([name, value]) => {
|
|
181
|
+
const parameter = reportSource.adapter.parameters.find((candidate) => candidate.name === name);
|
|
182
|
+
return filterExpression(semanticModel, parameter.target, parameterValues(parameter, value));
|
|
183
|
+
}).filter(Boolean);
|
|
184
|
+
const variables = [];
|
|
185
|
+
const rowSets = [];
|
|
186
|
+
|
|
187
|
+
widgets.forEach((widgetId, widgetIndex) => {
|
|
188
|
+
const widget = widgetById.get(widgetId);
|
|
189
|
+
const dimensions = widget.dimensions.map((dimension) => ({
|
|
190
|
+
key: semanticColumn(semanticModel, dimension.key),
|
|
191
|
+
label: semanticColumn(semanticModel, dimension.label),
|
|
192
|
+
}));
|
|
193
|
+
const axis = semanticColumn(semanticModel, widget.axis);
|
|
194
|
+
const measures = widget.measures.map((measure) => ({ reference: measure, ...semanticMeasure(semanticModel, measure) }));
|
|
195
|
+
const groupBy = [...new Set(dimensions.flatMap((dimension) => [dimension.key.dax, dimension.label.dax]))];
|
|
196
|
+
const table = semanticModel.tables.find((candidate) => candidate.name === axis.table);
|
|
197
|
+
const axisColumn = table?.columns?.find((candidate) => candidate.name === axis.column);
|
|
198
|
+
if (axisColumn?.sortByColumn) groupBy.push(semanticColumn(semanticModel, `${axis.table}[${axisColumn.sortByColumn}]`).dax);
|
|
199
|
+
const selectionFilters = Object.entries(selections)
|
|
200
|
+
.filter(([selectedWidget]) => selectedWidget !== widgetId)
|
|
201
|
+
.map(([selectedWidget, value]) => filterExpression(semanticModel, widgetById.get(selectedWidget).axis, [value]));
|
|
202
|
+
const filters = [...selectionFilters, ...parameterFilters].filter(Boolean);
|
|
203
|
+
const candidate = `__Widget${widgetIndex + 1}Candidates`;
|
|
204
|
+
const selected = `__Widget${widgetIndex + 1}Selected`;
|
|
205
|
+
const valueNames = measures.map((_measure, index) => `__Value${index + 1}`);
|
|
206
|
+
const summarized = [
|
|
207
|
+
...groupBy,
|
|
208
|
+
...filters,
|
|
209
|
+
...measures.flatMap((measure, index) => [daxString(valueNames[index]), `CALCULATE(${measure.dax})`]),
|
|
210
|
+
].join(",\n ");
|
|
211
|
+
const axisKey = dimensions.map((dimension) => axisKeyExpression(dimension.key)).join(' & "␟" & ');
|
|
212
|
+
const label = dimensions.map((dimension) => `FORMAT(${dimension.label.dax}, "")`).join(' & "␟" & ');
|
|
213
|
+
const order = ["top-by-value", "bottom-by-value"].includes(widget.rowSelection)
|
|
214
|
+
? `[${valueNames[0]}], ${widget.rowSelection === "bottom-by-value" ? "ASC" : "DESC"}, [__Sort], ASC`
|
|
215
|
+
: `[__Sort], ${widget.rowSelection === "last-by-axis" ? "DESC" : "ASC"}, [__AxisKey], ASC`;
|
|
216
|
+
variables.push(`VAR ${candidate} =
|
|
217
|
+
FILTER(
|
|
218
|
+
SELECTCOLUMNS(
|
|
219
|
+
SUMMARIZECOLUMNS(
|
|
220
|
+
${summarized}
|
|
221
|
+
),
|
|
222
|
+
"__AxisKey", ${axisKey},
|
|
223
|
+
"__Label", ${label},
|
|
224
|
+
"__Sort", ${sortExpression(semanticModel, axis)},
|
|
225
|
+
${measures.flatMap((_measure, index) => [daxString(valueNames[index]), `[${valueNames[index]}]`]).join(",\n ")}
|
|
226
|
+
),
|
|
227
|
+
NOT ISBLANK([${valueNames[0]}])
|
|
228
|
+
)
|
|
229
|
+
VAR ${selected} = TOPN(${widget.rowLimit}, ${candidate}, ${order})`);
|
|
230
|
+
measures.forEach((measure, measureIndex) => {
|
|
231
|
+
rowSets.push(`SELECTCOLUMNS(
|
|
232
|
+
${selected},
|
|
233
|
+
"WidgetId", ${daxString(widgetId)},
|
|
234
|
+
"AxisKey", [__AxisKey],
|
|
235
|
+
"Label", [__Label],
|
|
236
|
+
"Sort", [__Sort],
|
|
237
|
+
"SeriesKey", ${daxString(measure.reference)},
|
|
238
|
+
"SeriesLabel", ${daxString(measureLabel(measure.reference))},
|
|
239
|
+
"WidgetValue", [${valueNames[measureIndex]}],
|
|
240
|
+
"BrokerContext", ${daxString(Object.entries(selections).map(([id, value]) => `${id}=${value}`).join(";"))}
|
|
241
|
+
)`);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return `EVALUATE
|
|
246
|
+
${variables.join("\n")}
|
|
247
|
+
VAR __SuperVisualRows =
|
|
248
|
+
${rowSets.length === 1 ? rowSets[0] : `UNION(\n ${rowSets.join(",\n ")}\n )`}
|
|
249
|
+
RETURN
|
|
250
|
+
__SuperVisualRows
|
|
251
|
+
ORDER BY [WidgetId], [Sort], [AxisKey], [SeriesKey]`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function rowValue(row, field) {
|
|
255
|
+
if (Object.hasOwn(row, field)) return row[field];
|
|
256
|
+
if (Object.hasOwn(row, `[${field}]`)) return row[`[${field}]`];
|
|
257
|
+
const key = Object.keys(row).find((candidate) => candidate.endsWith(`[${field}]`));
|
|
258
|
+
return key ? row[key] : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function normalizeSemanticBrokerRows(rows) {
|
|
262
|
+
if (!Array.isArray(rows)) throw new Error("Power BI executeQueries did not return a row array");
|
|
263
|
+
return rows.map((row) => Object.fromEntries(BROKER_FIELDS.map((field) => [field, rowValue(row, field)])));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function usesSemanticCatalog(broker) {
|
|
267
|
+
return (broker.widgets ?? []).some((widget) => [
|
|
268
|
+
widget.axis,
|
|
269
|
+
...(widget.dimensions ?? []).flatMap((dimension) => [dimension.key, dimension.label]),
|
|
270
|
+
...(widget.measures ?? []),
|
|
271
|
+
].some((reference) => typeof reference === "string" && reference.startsWith("Super Visual Model Catalog[")));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function semanticCatalogRows(model) {
|
|
275
|
+
const entities = [{ type: "Model", parent: "", entity: model.displayName }];
|
|
276
|
+
for (const table of model.tables ?? []) {
|
|
277
|
+
if (table.name === "Widget Axis" || table.name === "Super Visual Model Catalog") continue;
|
|
278
|
+
entities.push({ type: "Table", parent: model.displayName, entity: table.name });
|
|
279
|
+
for (const column of table.columns ?? []) entities.push({ type: "Column", parent: table.name, entity: column.name });
|
|
280
|
+
for (const measure of table.measures ?? []) entities.push({ type: "Measure", parent: table.name, entity: measure.name });
|
|
281
|
+
for (const hierarchy of table.hierarchies ?? []) {
|
|
282
|
+
entities.push({ type: "Hierarchy", parent: table.name, entity: hierarchy.name });
|
|
283
|
+
for (const level of hierarchy.levels ?? []) entities.push({ type: "Hierarchy level", parent: `${table.name}.${hierarchy.name}`, entity: level.name });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const relationship of model.relationships ?? []) entities.push({ type: "Relationship", parent: `${relationship.from ?? ""} → ${relationship.to ?? ""}`, entity: relationship.name });
|
|
287
|
+
for (const parameter of model.parameters ?? []) entities.push({ type: "Parameter", parent: model.displayName, entity: parameter.name });
|
|
288
|
+
return entities.map((item, index) => ({
|
|
289
|
+
WidgetId: "model-catalog",
|
|
290
|
+
AxisKey: `${item.type}␟${item.parent}␟${item.entity}`,
|
|
291
|
+
Label: `${item.type}␟${item.parent}␟${item.entity}`,
|
|
292
|
+
Sort: index + 1,
|
|
293
|
+
SeriesKey: "[Entity Count]",
|
|
294
|
+
SeriesLabel: "Entity Count",
|
|
295
|
+
WidgetValue: 1,
|
|
296
|
+
BrokerContext: `model=${model.id}`,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function executeSemanticPreviewQuery({ client, workspaceId, semanticModelId, loaded, request }) {
|
|
301
|
+
const validated = validateQueryRequest(request, loaded);
|
|
302
|
+
const started = performance.now();
|
|
303
|
+
if (loaded.catalogRows && validated.widgets.every((widget) => widget === "model-catalog")) {
|
|
304
|
+
return { rows: loaded.catalogRows, metadata: { requestedWidgets: validated.widgets, rowCount: loaded.catalogRows.length, elapsedMs: Math.round(performance.now() - started) } };
|
|
305
|
+
}
|
|
306
|
+
const query = loaded.previewMode === "managed" ? buildManagedSemanticQuery(loaded, validated) : buildSemanticPreviewQuery(loaded, validated);
|
|
307
|
+
const rows = normalizeSemanticBrokerRows(await client.executeDax(workspaceId, semanticModelId, query));
|
|
308
|
+
return { rows, metadata: { requestedWidgets: validated.widgets, rowCount: rows.length, elapsedMs: Math.round(performance.now() - started) } };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function previewBrokerSurfaceDiagnostics(inspection) {
|
|
312
|
+
const broker = inspection.tables.find((table) => table.name === "Widget Axis");
|
|
313
|
+
if (!broker) return [{ code: "preview.broker.missing", message: "The semantic model does not contain the generated Widget Axis table." }];
|
|
314
|
+
const columns = new Set((broker.columns ?? []).map((column) => column.name));
|
|
315
|
+
const measures = new Set((broker.measures ?? []).map((measure) => measure.name));
|
|
316
|
+
return [
|
|
317
|
+
...BROKER_COLUMNS.filter((name) => !columns.has(name)).map((name) => ({ code: "preview.broker.column.missing", message: `Widget Axis[${name}] was not found.` })),
|
|
318
|
+
...BROKER_MEASURES.filter((name) => !measures.has(name)).map((name) => ({ code: "preview.broker.measure.missing", message: `[${name}] was not found on Widget Axis.` })),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function packageDirectory(name) {
|
|
323
|
+
let current = path.dirname(require.resolve(name));
|
|
324
|
+
while (current !== path.dirname(current) && !existsSync(path.join(current, "package.json"))) current = path.dirname(current);
|
|
325
|
+
if (!existsSync(path.join(current, "package.json"))) throw new Error(`Could not locate preview dependency ${name}`);
|
|
326
|
+
return current;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function dependencyAliases() {
|
|
330
|
+
return ["react", "react-dom", "react-router-dom", "@fontsource/source-serif-4", "@nivo/bar", "@nivo/line", "@nivo/pie", "@nivo/sankey", "@nivo/tree", "d3"]
|
|
331
|
+
.map((name) => ({ find: name, replacement: packageDirectory(name) }));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function readJsonBody(request, maximumBytes = 64 * 1024) {
|
|
335
|
+
const chunks = [];
|
|
336
|
+
let length = 0;
|
|
337
|
+
for await (const chunk of request) {
|
|
338
|
+
length += chunk.length;
|
|
339
|
+
if (length > maximumBytes) throw new Error("Preview request body exceeds 64 KiB");
|
|
340
|
+
chunks.push(chunk);
|
|
341
|
+
}
|
|
342
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function sendJson(response, status, value) {
|
|
346
|
+
const body = JSON.stringify(value);
|
|
347
|
+
response.writeHead(status, {
|
|
348
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
349
|
+
"Content-Length": Buffer.byteLength(body),
|
|
350
|
+
"Cache-Control": "no-store",
|
|
351
|
+
"X-Content-Type-Options": "nosniff",
|
|
352
|
+
});
|
|
353
|
+
response.end(body);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function semanticGateway({ client, workspaceId, semanticModelId, loaded, token }) {
|
|
357
|
+
return {
|
|
358
|
+
name: "supervisual-semantic-gateway",
|
|
359
|
+
configureServer(server) {
|
|
360
|
+
server.middlewares.use(async (request, response, next) => {
|
|
361
|
+
if (request.url !== "/__supervisual/query") return next();
|
|
362
|
+
const expectedOrigin = `http://${request.headers.host}`;
|
|
363
|
+
if (request.method !== "POST"
|
|
364
|
+
|| request.headers["content-type"]?.split(";", 1)[0] !== "application/json"
|
|
365
|
+
|| request.headers.origin !== expectedOrigin
|
|
366
|
+
|| request.headers["x-supervisual-preview-token"] !== token) {
|
|
367
|
+
return sendJson(response, 403, { ok: false, error: "Preview gateway request was rejected." });
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const result = await executeSemanticPreviewQuery({ client, workspaceId, semanticModelId, loaded, request: await readJsonBody(request) });
|
|
371
|
+
return sendJson(response, 200, { ok: true, ...result });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function previewResponseHeaders() {
|
|
381
|
+
return {
|
|
382
|
+
name: "supervisual-preview-response-headers",
|
|
383
|
+
configureServer(server) {
|
|
384
|
+
server.middlewares.use((_request, response, next) => {
|
|
385
|
+
for (const [name, value] of Object.entries(PREVIEW_RESPONSE_HEADERS)) response.setHeader(name, value);
|
|
386
|
+
next();
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function writePreviewHost(root, token) {
|
|
393
|
+
await rm(root, { recursive: true, force: true });
|
|
394
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
395
|
+
await cp(previewTemplate, root, { recursive: true });
|
|
396
|
+
await writeFile(path.join(root, "src", "config.ts"), `export const PREVIEW_TOKEN = ${JSON.stringify(token)};\n`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function startSemanticPreview({ client, projectFile, workspaceId, workspaceName, semanticModelId, host = "127.0.0.1", port = 5173, open = false }) {
|
|
400
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(host)) throw new Error("Semantic preview may bind only to a loopback host");
|
|
401
|
+
const loaded = await loadProject(projectFile);
|
|
402
|
+
const diagnostics = validateCompilation(loaded);
|
|
403
|
+
if (diagnostics.length) throw new Error(`Preview validation failed:\n- ${diagnostics.join("\n- ")}`);
|
|
404
|
+
const inspected = await inspectSemanticModel({ client, workspaceId, workspaceName, semanticModelId });
|
|
405
|
+
const catalogMode = usesSemanticCatalog(loaded.broker);
|
|
406
|
+
const brokerDiagnostics = previewBrokerSurfaceDiagnostics(inspected.semanticModel);
|
|
407
|
+
const previewMode = catalogMode ? "catalog" : brokerDiagnostics.length ? "direct" : "managed";
|
|
408
|
+
const compatibility = catalogMode
|
|
409
|
+
? { ok: true, summary: { required: 1, matched: 1, missing: 0, ambiguous: 0 }, checks: [], diagnostics: [] }
|
|
410
|
+
: checkSemanticCompatibility(inspected.semanticModel, loaded.semanticModel, loaded.broker);
|
|
411
|
+
if (!compatibility.ok) {
|
|
412
|
+
const messages = compatibility.diagnostics.map((item) => item.message);
|
|
413
|
+
throw new Error(`Semantic preview compatibility failed:\n- ${messages.join("\n- ")}`);
|
|
414
|
+
}
|
|
415
|
+
loaded.previewMode = previewMode;
|
|
416
|
+
if (catalogMode) loaded.catalogRows = semanticCatalogRows(inspected.semanticModel);
|
|
417
|
+
|
|
418
|
+
const previewRoot = path.join(loaded.root, ".supervisual", "semantic-preview");
|
|
419
|
+
const solutionRoot = path.join(previewRoot, "solution");
|
|
420
|
+
const hostRoot = path.join(previewRoot, "host");
|
|
421
|
+
await buildSolution(projectFile, { outputDirectory: solutionRoot });
|
|
422
|
+
const token = randomBytes(24).toString("base64url");
|
|
423
|
+
await writePreviewHost(hostRoot, token);
|
|
424
|
+
const server = await createServer({
|
|
425
|
+
root: hostRoot,
|
|
426
|
+
logLevel: "silent",
|
|
427
|
+
resolve: { alias: dependencyAliases(), dedupe: ["react", "react-dom"] },
|
|
428
|
+
server: {
|
|
429
|
+
host,
|
|
430
|
+
port: Number(port),
|
|
431
|
+
strictPort: Number(port) !== 0,
|
|
432
|
+
open,
|
|
433
|
+
allowedHosts: [host],
|
|
434
|
+
cors: false,
|
|
435
|
+
headers: PREVIEW_RESPONSE_HEADERS,
|
|
436
|
+
fs: { strict: true, allow: [previewRoot] },
|
|
437
|
+
},
|
|
438
|
+
plugins: [previewResponseHeaders(), semanticGateway({ client, workspaceId, semanticModelId, loaded, token })],
|
|
439
|
+
});
|
|
440
|
+
await server.listen();
|
|
441
|
+
const address = server.httpServer?.address();
|
|
442
|
+
const actualPort = typeof address === "object" && address ? address.port : Number(port);
|
|
443
|
+
const urlHost = host === "::1" ? "[::1]" : host;
|
|
444
|
+
return {
|
|
445
|
+
ok: true,
|
|
446
|
+
status: "ready",
|
|
447
|
+
mode: "live-semantic-model",
|
|
448
|
+
url: `http://${urlHost}:${actualPort}/`,
|
|
449
|
+
workspace: inspected.workspace,
|
|
450
|
+
semanticModel: { id: inspected.semanticModel.id, displayName: inspected.semanticModel.displayName },
|
|
451
|
+
compatibility: compatibility.summary,
|
|
452
|
+
project: projectFile,
|
|
453
|
+
generatedDirectory: previewRoot,
|
|
454
|
+
close: () => server.close(),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function runSemanticPreviewTest(options) {
|
|
459
|
+
const preview = await startSemanticPreview({ ...options, port: 0, open: false });
|
|
460
|
+
let browser;
|
|
461
|
+
try {
|
|
462
|
+
const { chromium } = await import("playwright");
|
|
463
|
+
browser = await chromium.launch({ headless: true });
|
|
464
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
465
|
+
const browserErrors = [];
|
|
466
|
+
page.on("pageerror", (error) => browserErrors.push(error.message));
|
|
467
|
+
await page.goto(preview.url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs });
|
|
468
|
+
try {
|
|
469
|
+
await page.waitForFunction(() => window.__SUPERVISUAL_PREVIEW__?.snapshot().status === "ready", null, { timeout: options.timeoutMs });
|
|
470
|
+
} catch (error) {
|
|
471
|
+
const snapshot = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__?.snapshot?.() ?? null).catch(() => null);
|
|
472
|
+
throw new Error(`Semantic preview did not become ready: ${JSON.stringify({ snapshot, browserErrors, cause: error instanceof Error ? error.message : String(error) })}`);
|
|
473
|
+
}
|
|
474
|
+
const baseline = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
|
|
475
|
+
if (baseline.receivedRowCount < 1) throw new Error("Semantic preview returned no broker rows");
|
|
476
|
+
|
|
477
|
+
let interaction = { available: false, tested: false, widget: null, selected: null };
|
|
478
|
+
let pagesTested = 1;
|
|
479
|
+
let selector = page.locator("select[data-filter-widget]").first();
|
|
480
|
+
if (!(await selector.count()) || await selector.locator("option").count() < 2) {
|
|
481
|
+
const links = page.locator('nav[aria-label="Report pages"] a');
|
|
482
|
+
for (let index = 0; index < await links.count(); index += 1) {
|
|
483
|
+
const beforeNavigation = (await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot())).requestSequence;
|
|
484
|
+
await links.nth(index).click();
|
|
485
|
+
pagesTested += 1;
|
|
486
|
+
await page.waitForFunction((sequence) => {
|
|
487
|
+
const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
|
|
488
|
+
return snapshot?.status === "ready" && snapshot.requestSequence > sequence;
|
|
489
|
+
}, beforeNavigation, { timeout: options.timeoutMs });
|
|
490
|
+
selector = page.locator("select[data-filter-widget]").first();
|
|
491
|
+
if (await selector.count() && await selector.locator("option").count() > 1) break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (await selector.count() && await selector.locator("option").count() > 1) {
|
|
495
|
+
interaction.available = true;
|
|
496
|
+
interaction.widget = await selector.getAttribute("data-filter-widget");
|
|
497
|
+
const before = (await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot())).requestSequence;
|
|
498
|
+
await selector.selectOption({ index: 1 });
|
|
499
|
+
await page.waitForFunction((sequence) => {
|
|
500
|
+
const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
|
|
501
|
+
return snapshot?.status === "ready" && snapshot.requestSequence > sequence && Object.keys(snapshot.activeSelections).length > 0;
|
|
502
|
+
}, before, { timeout: options.timeoutMs });
|
|
503
|
+
const selected = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
|
|
504
|
+
interaction.selected = { requestSequence: selected.requestSequence, receivedRowCount: selected.receivedRowCount, rowCounts: selected.rowCounts };
|
|
505
|
+
const beforeClear = selected.requestSequence;
|
|
506
|
+
await selector.selectOption("");
|
|
507
|
+
await page.waitForFunction((sequence) => {
|
|
508
|
+
const snapshot = window.__SUPERVISUAL_PREVIEW__?.snapshot();
|
|
509
|
+
return snapshot?.status === "ready" && snapshot.requestSequence > sequence && Object.keys(snapshot.activeSelections).length === 0;
|
|
510
|
+
}, beforeClear, { timeout: options.timeoutMs });
|
|
511
|
+
interaction.tested = true;
|
|
512
|
+
}
|
|
513
|
+
const final = await page.evaluate(() => window.__SUPERVISUAL_PREVIEW__.snapshot());
|
|
514
|
+
if (browserErrors.length) throw new Error(`Preview browser errors: ${browserErrors.join("; ")}`);
|
|
515
|
+
const evidence = (snapshot) => ({
|
|
516
|
+
status: snapshot.status,
|
|
517
|
+
requestSequence: snapshot.requestSequence,
|
|
518
|
+
receivedWidgets: snapshot.receivedWidgets,
|
|
519
|
+
receivedRowCount: snapshot.receivedRowCount,
|
|
520
|
+
rowCounts: snapshot.rowCounts,
|
|
521
|
+
error: snapshot.error,
|
|
522
|
+
});
|
|
523
|
+
return { ok: true, status: "passed", mode: preview.mode, workspace: preview.workspace, semanticModel: preview.semanticModel, baseline: evidence(baseline), final: evidence(final), pagesTested, interaction };
|
|
524
|
+
} finally {
|
|
525
|
+
await browser?.close();
|
|
526
|
+
await preview.close();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# {{PROJECT_TITLE}}
|
|
2
|
+
|
|
3
|
+
This is the smallest complete Super Visual project: one semantic table, one
|
|
4
|
+
measure, one broker dataset, one page, and one accessible React chart.
|
|
5
|
+
|
|
6
|
+
This project is ready for the three-command workflow. If `init` was given a
|
|
7
|
+
workspace and Power BI semantic model, run:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx supervisual dev --open
|
|
11
|
+
npx supervisual publish
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`publish` performs the checks, packaging, verification, and planning, then asks
|
|
15
|
+
for confirmation of the exact plan ID. For a project without a saved binding,
|
|
16
|
+
pass the IDs once:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx supervisual dev . --workspace-id <workspace-guid> \
|
|
20
|
+
--semantic-model-id <semantic-model-guid> --open
|
|
21
|
+
npx supervisual preview test . --workspace-id <workspace-guid> \
|
|
22
|
+
--semantic-model-id <semantic-model-guid> --json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Edit the contracts under `contracts/` to point at your governed data. Application
|
|
26
|
+
source receives data only through `ReportApplicationProps`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
contractVersion: 1
|
|
2
|
+
shell:
|
|
3
|
+
id: {{PROJECT_ID}}
|
|
4
|
+
title: {{PROJECT_TITLE}}
|
|
5
|
+
defaultPage: overview
|
|
6
|
+
designSystem:
|
|
7
|
+
id: hello-world
|
|
8
|
+
sha256: {{DESIGN_HASH}}
|
|
9
|
+
acceptedConcept: { id: {{PROJECT_ID}} }
|
|
10
|
+
governanceLabel: Power BI governed
|
|
11
|
+
locale: en-AU
|
|
12
|
+
currencyCode: AUD
|
|
13
|
+
filterContext: []
|
|
14
|
+
navigation:
|
|
15
|
+
- { page: overview, label: Overview }
|
|
16
|
+
theme:
|
|
17
|
+
background: '#F4F7F5'
|
|
18
|
+
surface: '#FFFFFF'
|
|
19
|
+
quietSurface: '#EAF0ED'
|
|
20
|
+
text: '#173C36'
|
|
21
|
+
mutedText: '#5E6D68'
|
|
22
|
+
metadata: '#71807A'
|
|
23
|
+
rule: '#D3DDD8'
|
|
24
|
+
grid: '#E3EAE6'
|
|
25
|
+
accent: '#167D6C'
|
|
26
|
+
attention: '#9B6B23'
|
|
27
|
+
positive: '#2E7D5B'
|
|
28
|
+
negative: '#B84A52'
|
|
29
|
+
context: ['#167D6C', '#6F6A9A']
|
|
30
|
+
dataColors: ['#167D6C', '#6F6A9A', '#9B6B23']
|
|
31
|
+
displayFont: Georgia, serif
|
|
32
|
+
bodyFont: Aptos, Segoe UI, sans-serif
|
|
33
|
+
contentWidth: 1200
|
|
34
|
+
layoutColumns: 12
|
|
35
|
+
layoutGap: 16
|
|
36
|
+
cardRadius: 8
|
|
37
|
+
cardPadding: [20, 20, 20, 20]
|
|
38
|
+
kpiTopAccent: 3
|
|
39
|
+
density: comfortable
|
|
40
|
+
typography: { findingMin: 40, findingMax: 56, sectionTitle: 22, kpiValue: 32, body: 15, chart: 12, table: 13, metadata: 11 }
|
|
41
|
+
pages:
|
|
42
|
+
- id: overview
|
|
43
|
+
title: Hello World
|
|
44
|
+
eyebrow: Super Visual / minimal example
|
|
45
|
+
summary:
|
|
46
|
+
headline: Your first governed React dashboard.
|
|
47
|
+
body: One dataset and one chart prove the complete semantic-to-visual path.
|
|
48
|
+
emphasisBinding: { dataset: sales, field: value, aggregation: sum }
|
|
49
|
+
search: null
|
|
50
|
+
kpis:
|
|
51
|
+
- id: total-sales
|
|
52
|
+
conceptRegionId: total-sales
|
|
53
|
+
label: Total sales
|
|
54
|
+
binding: { dataset: sales, field: value, aggregation: sum }
|
|
55
|
+
reportWidgetIds: [sales]
|
|
56
|
+
businessQuestionIds: [sales-mix]
|
|
57
|
+
format: currency-compact
|
|
58
|
+
comparison: null
|
|
59
|
+
widgets:
|
|
60
|
+
- id: sales
|
|
61
|
+
conceptRegionId: sales
|
|
62
|
+
primitive: bar
|
|
63
|
+
title: Sales by category
|
|
64
|
+
subtitle: Governed data from the semantic model
|
|
65
|
+
dataset: sales
|
|
66
|
+
reportWidgetIds: [sales]
|
|
67
|
+
businessQuestionIds: [sales-mix]
|
|
68
|
+
layout: { columns: 12, minHeight: 320 }
|
|
69
|
+
settings: { orientation: horizontal, curve: null, showLegend: false, showLabels: true, sort: descending, limit: 10, emphasis: bars }
|
|
70
|
+
interactions: []
|
|
71
|
+
designRationale:
|
|
72
|
+
narrative: Demonstrate the smallest complete Super Visual project.
|
|
73
|
+
decisions:
|
|
74
|
+
- Keep the example to one page, one dataset, one measure, and one chart.
|
|
75
|
+
accessibility:
|
|
76
|
+
- Use semantic headings, a native list, visible values, and no colour-only meaning.
|
|
77
|
+
risks:
|
|
78
|
+
- A Fabric deployment still requires a reachable SQL endpoint and connection binding.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contractVersion": 1,
|
|
3
|
+
"designSystem": { "id": "hello-world", "sha256": "{{DESIGN_HASH}}" },
|
|
4
|
+
"acceptedConcept": { "id": "{{PROJECT_ID}}" },
|
|
5
|
+
"entry": "src/reportApplication.tsx",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "src/reportApplication.tsx",
|
|
9
|
+
"content": "import { useEffect, type ReactElement } from \"react\";\nimport type { ReportApplicationProps } from \"./reportRuntime\";\n\nexport function ReportApplication({ model, onVisibleDatasetsChange }: ReportApplicationProps): ReactElement {\n useEffect(() => onVisibleDatasetsChange?.([\"sales\"]), [onVisibleDatasetsChange]);\n const dataset = model.datasets.sales;\n const maximum = Math.max(1, ...(dataset?.points.map((point) => point.value) ?? [1]));\n const total = dataset?.points.reduce((sum, point) => sum + point.value, 0) ?? 0;\n\n return <main className=\"hello-world\">\n <p className=\"eyebrow\">Super Visual / minimal example</p>\n <h1>Hello, governed data.</h1>\n <p className=\"lede\">This React page is bound only through ReportApplicationProps.</p>\n <section aria-labelledby=\"total-sales\">\n <h2 id=\"total-sales\">Total sales</h2>\n <strong className=\"total\">{new Intl.NumberFormat(undefined, { style: \"currency\", currency: \"AUD\", maximumFractionDigits: 0 }).format(total)}</strong>\n </section>\n <section aria-labelledby=\"sales-by-category\">\n <h2 id=\"sales-by-category\">Sales by category</h2>\n {!dataset?.points.length ? <p>Waiting for Power BI data…</p> : <ol className=\"bars\">\n {dataset.points.map((point) => <li key={point.key}>\n <span>{point.label}</span>\n <span className=\"track\"><span className=\"bar\" style={{ width: `${Math.max(2, (point.value / maximum) * 100)}%` }} /></span>\n <strong>{new Intl.NumberFormat().format(point.value)}</strong>\n </li>)}\n </ol>}\n </section>\n </main>;\n}\n"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"path": "style/report-design.less",
|
|
13
|
+
"content": ".hello-world { box-sizing: border-box; min-height: 100%; padding: 32px; color: #173c36; background: #f4f7f5; font-family: Aptos, \"Segoe UI\", sans-serif; }\n.hello-world .eyebrow { margin: 0 0 8px; color: #167d6c; font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }\n.hello-world h1 { margin: 0; font: 600 clamp(36px, 6vw, 64px)/1 Georgia, serif; }\n.hello-world .lede { max-width: 620px; color: #5e6d68; font-size: 17px; }\n.hello-world section { margin-top: 28px; padding: 24px; border: 1px solid #d3ddd8; border-radius: 8px; background: #fff; }\n.hello-world h2 { margin: 0 0 16px; font-size: 18px; }\n.hello-world .total { font: 600 42px/1 Georgia, serif; }\n.hello-world .bars { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }\n.hello-world .bars li { display: grid; grid-template-columns: minmax(90px, 1fr) 3fr minmax(70px, auto); gap: 12px; align-items: center; }\n.hello-world .track { height: 14px; overflow: hidden; border-radius: 7px; background: #eaf0ed; }\n.hello-world .bar { display: block; height: 100%; border-radius: inherit; background: #167d6c; }\n@media (max-width: 640px) { .hello-world { padding: 18px; } .hello-world .bars li { grid-template-columns: 1fr auto; } .hello-world .track { grid-column: 1 / -1; } }\n"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"routes": [
|
|
17
|
+
{ "id": "overview", "title": "Hello World", "datasets": ["sales"], "businessQuestionIds": ["sales-mix"] }
|
|
18
|
+
],
|
|
19
|
+
"bindings": [
|
|
20
|
+
{ "dataset": "sales", "component": "bar", "reportWidgetIds": ["sales"], "businessQuestionIds": ["sales-mix"] }
|
|
21
|
+
],
|
|
22
|
+
"designRationale": {
|
|
23
|
+
"direction": "The smallest complete governed analytical application.",
|
|
24
|
+
"pageCompositions": ["One overview page"],
|
|
25
|
+
"dataVisualisation": ["One accessible horizontal bar chart"],
|
|
26
|
+
"responsiveBehaviour": ["The label and chart track stack on narrow screens."],
|
|
27
|
+
"accessibility": ["Semantic headings, native list structure, and visible numeric values."],
|
|
28
|
+
"risks": ["Live deployment requires a Fabric connection binding."]
|
|
29
|
+
}
|
|
30
|
+
}
|