@bluemarble/bi 0.0.0-stage → 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.
- package/README.md +76 -2
- package/dist/DashboardDevtools-DLhEr3fN.js +6384 -0
- package/dist/DashboardDevtools-DLhEr3fN.js.map +1 -0
- package/dist/ModelDictionary-DU6i_QSn.js +492 -0
- package/dist/ModelDictionary-DU6i_QSn.js.map +1 -0
- package/dist/client/index.d.ts +410 -0
- package/dist/client/index.js +1325 -0
- package/dist/client/index.js.map +1 -0
- package/dist/compare-DloZhg1T.js +58 -0
- package/dist/compare-DloZhg1T.js.map +1 -0
- package/dist/layout-iPByu6V0.js +899 -0
- package/dist/layout-iPByu6V0.js.map +1 -0
- package/dist/server/index.d.ts +171 -0
- package/dist/server/index.js +744 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types-CB4sK4jP.d.ts +371 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +0 -0
- package/package.json +75 -4
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
//#region src/star.ts
|
|
2
|
+
/** `'tabela.coluna'` -> `{ table, column }` (nulo se não tiver esse formato). */
|
|
3
|
+
function parseColumnRef(ref) {
|
|
4
|
+
if (typeof ref !== "string") return null;
|
|
5
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(ref.trim());
|
|
6
|
+
return match ? {
|
|
7
|
+
table: match[1],
|
|
8
|
+
column: match[2]
|
|
9
|
+
} : null;
|
|
10
|
+
}
|
|
11
|
+
/** Relacionamentos válidos do modelo, como arestas N -> 1. */
|
|
12
|
+
function modelEdges(model) {
|
|
13
|
+
return (model.relationships ?? []).flatMap((relationship, index) => {
|
|
14
|
+
const from = parseColumnRef(relationship?.from);
|
|
15
|
+
const to = parseColumnRef(relationship?.to);
|
|
16
|
+
return from && to ? [{
|
|
17
|
+
from,
|
|
18
|
+
to,
|
|
19
|
+
index,
|
|
20
|
+
inner: relationship.inner === true
|
|
21
|
+
}] : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
/**
|
|
26
|
+
* Tabelas citadas no SQL como `tabela.coluna`. O que está entre aspas simples é ignorado, para
|
|
27
|
+
* `'a.b'` num literal não contar como referência.
|
|
28
|
+
*/
|
|
29
|
+
function referencedTables(sql, tables) {
|
|
30
|
+
const code = sql.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
31
|
+
return tables.filter((name) => new RegExp(`(?<![\\w.\`])${escapeRegex(name)}\\.`).test(code));
|
|
32
|
+
}
|
|
33
|
+
/** Busca em largura a partir de `start`, seguindo os relacionamentos no sentido N -> 1. */
|
|
34
|
+
function reachFrom(start, edges) {
|
|
35
|
+
const paths = /* @__PURE__ */ new Map([[start, []]]);
|
|
36
|
+
let frontier = [start];
|
|
37
|
+
while (frontier.length) {
|
|
38
|
+
const next = [];
|
|
39
|
+
for (const table of frontier) for (const edge of edges) {
|
|
40
|
+
if (edge.from.table !== table || paths.has(edge.to.table)) continue;
|
|
41
|
+
paths.set(edge.to.table, [...paths.get(table) ?? [], {
|
|
42
|
+
table: edge.to.table,
|
|
43
|
+
edge
|
|
44
|
+
}]);
|
|
45
|
+
next.push(edge.to.table);
|
|
46
|
+
}
|
|
47
|
+
frontier = next;
|
|
48
|
+
}
|
|
49
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
50
|
+
for (const edge of edges) if (paths.has(edge.from.table)) incoming.set(edge.to.table, (incoming.get(edge.to.table) ?? 0) + 1);
|
|
51
|
+
return {
|
|
52
|
+
paths,
|
|
53
|
+
ambiguous: [...incoming].filter(([table, count]) => table !== start && count > 1).map(([table]) => table)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Tabelas que precisam entrar na consulta, na ordem do JOIN, para que a partir de `home` se chegue
|
|
58
|
+
* a todas as `targets`. Como cada tabela é alcançada por um caminho só (o modelo valida ambiguidade),
|
|
59
|
+
* a união dos caminhos é uma árvore: cada tabela entra uma vez, ligada pela aresta de quem veio antes.
|
|
60
|
+
*/
|
|
61
|
+
function joinPlan(home, targets, edges) {
|
|
62
|
+
const { paths } = reachFrom(home, edges);
|
|
63
|
+
const plan = [];
|
|
64
|
+
const added = /* @__PURE__ */ new Set([home]);
|
|
65
|
+
const include = (target) => {
|
|
66
|
+
for (const step of paths.get(target) ?? []) {
|
|
67
|
+
if (added.has(step.table)) continue;
|
|
68
|
+
added.add(step.table);
|
|
69
|
+
plan.push(step);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
for (const target of targets) include(target);
|
|
73
|
+
for (let grew = true; grew;) {
|
|
74
|
+
grew = false;
|
|
75
|
+
for (const edge of edges) if (edge.inner && added.has(edge.from.table) && !added.has(edge.to.table)) {
|
|
76
|
+
include(edge.to.table);
|
|
77
|
+
grew = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const depth = (table) => paths.get(table)?.length ?? 0;
|
|
81
|
+
return plan.sort((a, b) => depth(a.table) - depth(b.table));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Tabela de origem de uma medida `sum`: entre as tabelas que o SQL cita, a que alcança todas as
|
|
85
|
+
* outras (ex.: `fPesagem.peso * dProduto.fator` agrega em `fPesagem`). Nulo se nenhuma alcança.
|
|
86
|
+
*/
|
|
87
|
+
function measureHome(tables, edges) {
|
|
88
|
+
if (!tables.length) return null;
|
|
89
|
+
return tables.find((candidate) => {
|
|
90
|
+
const { paths } = reachFrom(candidate, edges);
|
|
91
|
+
return tables.every((table) => paths.has(table));
|
|
92
|
+
}) ?? null;
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/model.ts
|
|
96
|
+
const VALUE_FORMATS = [
|
|
97
|
+
"number",
|
|
98
|
+
"integer",
|
|
99
|
+
"currency",
|
|
100
|
+
"percent"
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* Agregações sobre uma expressão SQL (`tabela.coluna`). Todas valem nos dois formatos; no estrela,
|
|
104
|
+
* agregam no grão da tabela de origem, como a soma.
|
|
105
|
+
*/
|
|
106
|
+
const AGGREGATIONS = [
|
|
107
|
+
"sum",
|
|
108
|
+
"count",
|
|
109
|
+
"countDistinct",
|
|
110
|
+
"avg",
|
|
111
|
+
"min",
|
|
112
|
+
"max"
|
|
113
|
+
];
|
|
114
|
+
const AGGREGATION_LABEL = {
|
|
115
|
+
sum: "Soma",
|
|
116
|
+
count: "Contagem",
|
|
117
|
+
countDistinct: "Contagem distinta",
|
|
118
|
+
avg: "Média",
|
|
119
|
+
min: "Mínimo",
|
|
120
|
+
max: "Máximo"
|
|
121
|
+
};
|
|
122
|
+
const isAggregate = (measure) => AGGREGATIONS.includes(measure.type);
|
|
123
|
+
/** O SQL agregado da medida: `SUM(x)`, `COUNT(DISTINCT x)`, `AVG(x)`... */
|
|
124
|
+
function aggregateSql(measure) {
|
|
125
|
+
if (measure.type === "countDistinct") return `COUNT(DISTINCT ${measure.sql})`;
|
|
126
|
+
return `${{
|
|
127
|
+
sum: "SUM",
|
|
128
|
+
count: "COUNT",
|
|
129
|
+
avg: "AVG",
|
|
130
|
+
min: "MIN",
|
|
131
|
+
max: "MAX"
|
|
132
|
+
}[measure.type]}(${measure.sql})`;
|
|
133
|
+
}
|
|
134
|
+
/** Nomes citados como `{nome}` numa fórmula. */
|
|
135
|
+
const formulaRefs = (expression) => [...new Set([...expression.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1]))];
|
|
136
|
+
/** Medidas que uma medida usa diretamente (razão e fórmula). */
|
|
137
|
+
function measureDeps(measure) {
|
|
138
|
+
if (measure.type === "ratio") return [measure.numerator, measure.denominator];
|
|
139
|
+
if (measure.type === "formula") return formulaRefs(measure.expression);
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
143
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
|
+
const isText = (value) => typeof value === "string" && !!value.trim();
|
|
145
|
+
function metaErrors(context, field) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
for (const key of ["label", "description"]) if (field[key] !== void 0 && typeof field[key] !== "string") errors.push(`${context}: \`${key}\` precisa ser texto`);
|
|
148
|
+
if (field.format !== void 0 && !VALUE_FORMATS.includes(field.format)) errors.push(`${context}: formato inválido "${field.format}"`);
|
|
149
|
+
const { decimals } = field;
|
|
150
|
+
if (decimals !== void 0 && (typeof decimals !== "number" || !Number.isInteger(decimals) || decimals < 0 || decimals > 10)) errors.push(`${context}: \`decimals\` precisa ser um inteiro entre 0 e 10`);
|
|
151
|
+
return errors;
|
|
152
|
+
}
|
|
153
|
+
function modelMeta(model) {
|
|
154
|
+
const pick = (field) => ({
|
|
155
|
+
...field.label !== void 0 && { label: field.label },
|
|
156
|
+
...field.description !== void 0 && { description: field.description },
|
|
157
|
+
...field.format !== void 0 && { format: field.format },
|
|
158
|
+
...field.decimals !== void 0 && { decimals: field.decimals }
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
name: model.name,
|
|
162
|
+
dimensions: Object.fromEntries(Object.entries(model.dimensions).map(([name, dim]) => [name, {
|
|
163
|
+
type: dim.type,
|
|
164
|
+
...pick(dim)
|
|
165
|
+
}])),
|
|
166
|
+
measures: Object.fromEntries(Object.entries(model.measures).map(([name, measure]) => [name, {
|
|
167
|
+
type: measure.type,
|
|
168
|
+
...(measure.type === "count" || measure.type === "countDistinct") && { format: "integer" },
|
|
169
|
+
...pick(measure),
|
|
170
|
+
...measure.type === "ratio" && {
|
|
171
|
+
numerator: measure.numerator,
|
|
172
|
+
denominator: measure.denominator
|
|
173
|
+
},
|
|
174
|
+
...measure.type === "formula" && { refs: formulaRefs(measure.expression) }
|
|
175
|
+
}]))
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Lista os problemas do modelo (vazio = válido). Confere também a forma do objeto, porque o rascunho
|
|
180
|
+
* do devtools chega como JSON da request.
|
|
181
|
+
*/
|
|
182
|
+
function validateModel(model) {
|
|
183
|
+
if (!isRecord$1(model)) return ["modelo inválido"];
|
|
184
|
+
if (!isRecord$1(model.dimensions) || !isRecord$1(model.measures)) return ["`dimensions` e `measures` são obrigatórios"];
|
|
185
|
+
if ("base" in model || "joins" in model) return ["o formato `base` + `joins` não existe mais: use `tables` + `relationships`"];
|
|
186
|
+
if (!isRecord$1(model.tables)) return ["`tables` é obrigatório"];
|
|
187
|
+
if (model.relationships !== void 0 && !Array.isArray(model.relationships)) return ["`relationships` precisa ser uma lista"];
|
|
188
|
+
if (model.diagram !== void 0 && !diagramValid(model.diagram)) return ["`diagram` precisa ser { nome: { x, y } } com números"];
|
|
189
|
+
const errors = [];
|
|
190
|
+
const { tables } = model;
|
|
191
|
+
const names = [...Object.keys(model.dimensions), ...Object.keys(model.measures)];
|
|
192
|
+
for (const name of [...names, ...Object.keys(tables)]) if (!IDENTIFIER.test(name)) errors.push(`nome inválido: "${name}"`);
|
|
193
|
+
else if (name.startsWith("__")) errors.push(`nome reservado: "${name}" (começa com "__")`);
|
|
194
|
+
const duplicated = names.find((name, index) => names.indexOf(name) !== index);
|
|
195
|
+
if (duplicated) errors.push(`nome repetido: "${duplicated}"`);
|
|
196
|
+
for (const [name, table] of Object.entries(tables)) {
|
|
197
|
+
if (!isRecord$1(table) || !isText(table.sql)) {
|
|
198
|
+
errors.push(`tabela "${name}" sem SQL`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (table.key !== void 0 && (typeof table.key !== "string" || !IDENTIFIER.test(table.key))) errors.push(`tabela "${name}": key inválida`);
|
|
202
|
+
errors.push(...metaErrors(`tabela "${name}"`, table));
|
|
203
|
+
}
|
|
204
|
+
const tableNames = Object.keys(tables);
|
|
205
|
+
errors.push(...relationshipErrors(model, tableNames));
|
|
206
|
+
for (const [name, dimension] of Object.entries(model.dimensions)) if (!isRecord$1(dimension) || !isText(dimension.sql)) errors.push(`dimensão "${name}" sem SQL`);
|
|
207
|
+
else if (dimension.type !== "number" && dimension.type !== "string") errors.push(`dimensão "${name}" com tipo inválido`);
|
|
208
|
+
else {
|
|
209
|
+
errors.push(...metaErrors(`dimensão "${name}"`, dimension));
|
|
210
|
+
if (referencedTables(dimension.sql, tableNames).length !== 1) errors.push(`dimensão "${name}" precisa usar colunas de uma tabela só (\`tabela.coluna\`)`);
|
|
211
|
+
}
|
|
212
|
+
const edges = modelEdges(model);
|
|
213
|
+
for (const [name, measure] of Object.entries(model.measures)) {
|
|
214
|
+
if (!isRecord$1(measure)) errors.push(`medida "${name}" inválida`);
|
|
215
|
+
else if (isAggregate(measure)) {
|
|
216
|
+
if (!isText(measure.sql)) errors.push(`medida "${name}" sem SQL`);
|
|
217
|
+
else {
|
|
218
|
+
const used = referencedTables(measure.sql, tableNames);
|
|
219
|
+
if (!used.length) errors.push(`medida "${name}" precisa usar colunas de uma tabela (\`tabela.coluna\`)`);
|
|
220
|
+
else if (!measureHome(used, edges)) errors.push(`medida "${name}" usa ${used.join(", ")}, mas nenhuma delas chega às outras pelos relacionamentos`);
|
|
221
|
+
}
|
|
222
|
+
} else if (measure.type === "ratio" || measure.type === "formula") {
|
|
223
|
+
if (measure.type === "formula" && !isText(measure.expression)) errors.push(`medida "${name}" sem expressão`);
|
|
224
|
+
else if (measure.type === "formula" && !formulaRefs(measure.expression).length) errors.push(`medida "${name}": a fórmula precisa citar medidas como {nome}`);
|
|
225
|
+
for (const ref of measureDeps(measure)) if (typeof ref !== "string" || !model.measures[ref]) errors.push(`medida "${name}" referencia "${ref}", que não existe`);
|
|
226
|
+
} else errors.push(`medida "${name}" com tipo inválido`);
|
|
227
|
+
if (isRecord$1(measure)) errors.push(...metaErrors(`medida "${name}"`, measure));
|
|
228
|
+
}
|
|
229
|
+
if (errors.length) return errors;
|
|
230
|
+
const visit = (name, stack) => {
|
|
231
|
+
if (stack.includes(name)) return [...stack, name].join(" -> ");
|
|
232
|
+
for (const dep of measureDeps(model.measures[name])) {
|
|
233
|
+
const cycle = visit(dep, [...stack, name]);
|
|
234
|
+
if (cycle) return cycle;
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
};
|
|
238
|
+
for (const name of Object.keys(model.measures)) {
|
|
239
|
+
const cycle = visit(name, []);
|
|
240
|
+
if (cycle) {
|
|
241
|
+
errors.push(`medida circular: ${cycle}`);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return errors;
|
|
246
|
+
}
|
|
247
|
+
const diagramValid = (diagram) => isRecord$1(diagram) && Object.values(diagram).every((point) => isRecord$1(point) && Number.isFinite(point.x) && Number.isFinite(point.y));
|
|
248
|
+
function relationshipErrors(model, tableNames) {
|
|
249
|
+
const errors = [];
|
|
250
|
+
const seen = /* @__PURE__ */ new Set();
|
|
251
|
+
for (const [index, relationship] of (model.relationships ?? []).entries()) {
|
|
252
|
+
const context = `relacionamento ${index + 1}`;
|
|
253
|
+
if (!isRecord$1(relationship)) {
|
|
254
|
+
errors.push(`${context} inválido`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const from = parseColumnRef(relationship.from);
|
|
258
|
+
const to = parseColumnRef(relationship.to);
|
|
259
|
+
if (!from || !to) {
|
|
260
|
+
errors.push(`${context}: use \`tabela.coluna\` em \`from\` e \`to\``);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const ref of [from, to]) if (!tableNames.includes(ref.table)) errors.push(`${context}: tabela "${ref.table}" não existe`);
|
|
264
|
+
if (from.table === to.table) errors.push(`${context}: liga a tabela "${from.table}" a ela mesma`);
|
|
265
|
+
if (relationship.inner !== void 0 && typeof relationship.inner !== "boolean") errors.push(`${context}: \`inner\` precisa ser true/false`);
|
|
266
|
+
const pair = `${from.table}->${to.table}`;
|
|
267
|
+
if (seen.has(pair)) errors.push(`${context}: já existe um relacionamento de "${from.table}" para "${to.table}"`);
|
|
268
|
+
seen.add(pair);
|
|
269
|
+
}
|
|
270
|
+
if (errors.length) return errors;
|
|
271
|
+
const edges = modelEdges(model);
|
|
272
|
+
for (const table of tableNames) {
|
|
273
|
+
const { ambiguous, paths } = reachFrom(table, edges);
|
|
274
|
+
if (ambiguous.length) {
|
|
275
|
+
errors.push(`caminho ambíguo de "${table}" até "${ambiguous[0]}": há mais de um relacionamento levando lá`);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
if (edges.some((edge) => edge.to.table === table && paths.has(edge.from.table))) {
|
|
279
|
+
errors.push(`relacionamentos em ciclo passando por "${table}"`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return errors;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/codegen.ts
|
|
287
|
+
/**
|
|
288
|
+
* Gera o código TS de um modelo (`defineModel({...})`). Usado pelo "Copiar TS" do devtools e pelo
|
|
289
|
+
* "Salvar no arquivo" do servidor, que troca só a chamada do `defineModel` dentro do arquivo.
|
|
290
|
+
*/
|
|
291
|
+
/** JSON com as chaves ordenadas: compara modelos sem depender da ordem das propriedades. */
|
|
292
|
+
function canonicalJson(value) {
|
|
293
|
+
return JSON.stringify(value, (_, item) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
294
|
+
}
|
|
295
|
+
function literal(text) {
|
|
296
|
+
if (text.includes("\n")) return `\`${text.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")}\``;
|
|
297
|
+
return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
298
|
+
}
|
|
299
|
+
function metaEntries(field) {
|
|
300
|
+
const entries = [];
|
|
301
|
+
if (field.label !== void 0) entries.push(`label: ${literal(field.label)}`);
|
|
302
|
+
if (field.description !== void 0) entries.push(`description: ${literal(field.description)}`);
|
|
303
|
+
if (field.format !== void 0) entries.push(`format: '${field.format}'`);
|
|
304
|
+
if (field.decimals !== void 0) entries.push(`decimals: ${field.decimals}`);
|
|
305
|
+
return entries;
|
|
306
|
+
}
|
|
307
|
+
function measureCode(measure) {
|
|
308
|
+
const meta = metaEntries(measure);
|
|
309
|
+
const args = isAggregate(measure) ? [literal(measure.sql)] : measure.type === "formula" ? [literal(measure.expression)] : [literal(measure.numerator), literal(measure.denominator)];
|
|
310
|
+
if (meta.length) args.push(`{ ${meta.join(", ")} }`);
|
|
311
|
+
return `${measure.type}(${args.join(", ")})`;
|
|
312
|
+
}
|
|
313
|
+
function tableCode(table) {
|
|
314
|
+
return `{ ${[
|
|
315
|
+
...table.key !== void 0 ? [`key: ${literal(table.key)}`] : [],
|
|
316
|
+
`sql: ${literal(table.sql)}`,
|
|
317
|
+
...metaEntries(table)
|
|
318
|
+
].join(", ")} }`;
|
|
319
|
+
}
|
|
320
|
+
const entries = (record, render) => Object.entries(record).map(([name, value]) => ` ${name}: ${render(value)}`).join(",\n");
|
|
321
|
+
/** O objeto passado ao `defineModel`, começando em `{` (sem indentação na primeira linha). */
|
|
322
|
+
function modelObjectCode(model) {
|
|
323
|
+
const lines = ["{", ` name: ${literal(model.name)},`];
|
|
324
|
+
lines.push(Object.keys(model.tables).length ? ` tables: {\n${entries(model.tables, tableCode)}\n },` : " tables: {},");
|
|
325
|
+
if (model.relationships?.length) lines.push(` relationships: [\n${model.relationships.map((relationship) => ` { from: ${literal(relationship.from)}, to: ${literal(relationship.to)}${relationship.inner ? ", inner: true" : ""} }`).join(",\n")}\n ],`);
|
|
326
|
+
lines.push(` dimensions: {\n${entries(model.dimensions, (dim) => [
|
|
327
|
+
`{ sql: ${literal(dim.sql)}`,
|
|
328
|
+
`type: '${dim.type}'`,
|
|
329
|
+
...metaEntries(dim)
|
|
330
|
+
].join(", ").concat(" }"))}\n },`, ` measures: {\n${entries(model.measures, measureCode)}\n }${model.diagram ? "," : ""}`);
|
|
331
|
+
if (model.diagram) lines.push(` diagram: {\n${entries(model.diagram, ({ x, y }) => `{ x: ${x}, y: ${y} }`)}\n }`);
|
|
332
|
+
lines.push("}");
|
|
333
|
+
return lines.join("\n");
|
|
334
|
+
}
|
|
335
|
+
/** Helpers do `@bluemarble/bi/server` que o código do modelo usa. */
|
|
336
|
+
function modelImports(model) {
|
|
337
|
+
return ["defineModel", ...new Set(Object.values(model.measures).map((measure) => measure.type))].sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
|
|
338
|
+
}
|
|
339
|
+
/** Arquivo completo, pronto para colar em `src/dashboard/models/<nome>.ts`. */
|
|
340
|
+
function toModelCode(model) {
|
|
341
|
+
return [
|
|
342
|
+
`import { ${modelImports(model).join(", ")} } from '@bluemarble/bi/server'`,
|
|
343
|
+
"",
|
|
344
|
+
`export const ${model.name} = defineModel(${modelObjectCode(model)})`,
|
|
345
|
+
""
|
|
346
|
+
].join("\n");
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/layout.ts
|
|
350
|
+
/**
|
|
351
|
+
* Layout de gráficos de uma página: lista declarativa (JSON) que o `<DashboardCharts>` renderiza. É
|
|
352
|
+
* JSON — e não JSX — para o devtools conseguir adicionar e editar gráficos pelo navegador e gravar o
|
|
353
|
+
* arquivo de volta. Fica fora de `server/` porque o client e o servidor validam o mesmo formato.
|
|
354
|
+
*
|
|
355
|
+
* Os campos de cada tipo são descritos em `CHART_FIELDS`: é de lá que saem a validação, os avisos
|
|
356
|
+
* de dimensão/medida inexistente e o formulário da aba Layout do devtools.
|
|
357
|
+
*/
|
|
358
|
+
const CHART_TYPES = [
|
|
359
|
+
"kpi",
|
|
360
|
+
"combo",
|
|
361
|
+
"bar",
|
|
362
|
+
"donut",
|
|
363
|
+
"table"
|
|
364
|
+
];
|
|
365
|
+
const CHART_TYPE_LABEL = {
|
|
366
|
+
kpi: "KPI (card)",
|
|
367
|
+
combo: "Combo (barras + linha)",
|
|
368
|
+
bar: "Barras",
|
|
369
|
+
donut: "Rosca",
|
|
370
|
+
table: "Tabela"
|
|
371
|
+
};
|
|
372
|
+
const LAYOUT_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
373
|
+
const common = (height) => ({
|
|
374
|
+
title: {
|
|
375
|
+
kind: "text",
|
|
376
|
+
label: "Título",
|
|
377
|
+
section: "Gráfico"
|
|
378
|
+
},
|
|
379
|
+
span: {
|
|
380
|
+
kind: "integer",
|
|
381
|
+
min: 1,
|
|
382
|
+
max: 12,
|
|
383
|
+
default: 12,
|
|
384
|
+
label: "Largura",
|
|
385
|
+
hint: "Colunas ocupadas em telas largas; no celular, cada gráfico ocupa a linha toda (menos com a tela de tamanho fixo)",
|
|
386
|
+
section: "Gráfico"
|
|
387
|
+
},
|
|
388
|
+
height: {
|
|
389
|
+
kind: "integer",
|
|
390
|
+
min: 120,
|
|
391
|
+
max: 2e3,
|
|
392
|
+
default: height,
|
|
393
|
+
label: "Altura (px)",
|
|
394
|
+
section: "Gráfico"
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
const formatDimension = {
|
|
398
|
+
kind: "formatter",
|
|
399
|
+
label: "Formatar dimensão",
|
|
400
|
+
hint: "Formatters passados ao <DashboardCharts formatters={{ ... }}> da página",
|
|
401
|
+
section: "Dados"
|
|
402
|
+
};
|
|
403
|
+
const clickToFilter = {
|
|
404
|
+
kind: "boolean",
|
|
405
|
+
default: false,
|
|
406
|
+
label: "Clique filtra o dashboard",
|
|
407
|
+
hint: "Ctrl/Cmd+clique acumula; clicar de novo no único selecionado limpa",
|
|
408
|
+
section: "Dados"
|
|
409
|
+
};
|
|
410
|
+
const hideEmpty = {
|
|
411
|
+
kind: "boolean",
|
|
412
|
+
default: true,
|
|
413
|
+
label: "Esconder grupos vazios",
|
|
414
|
+
section: "Dados"
|
|
415
|
+
};
|
|
416
|
+
const CHART_FIELDS = {
|
|
417
|
+
kpi: {
|
|
418
|
+
title: {
|
|
419
|
+
kind: "text",
|
|
420
|
+
label: "Título",
|
|
421
|
+
section: "Gráfico"
|
|
422
|
+
},
|
|
423
|
+
span: common(0).span,
|
|
424
|
+
measure: {
|
|
425
|
+
kind: "measure",
|
|
426
|
+
required: true,
|
|
427
|
+
label: "Medida",
|
|
428
|
+
section: "Dados"
|
|
429
|
+
},
|
|
430
|
+
secondary: {
|
|
431
|
+
kind: "measure",
|
|
432
|
+
label: "Medida secundária",
|
|
433
|
+
hint: "Linha menor embaixo do número (ex.: volume, custo/t)",
|
|
434
|
+
section: "Dados"
|
|
435
|
+
},
|
|
436
|
+
secondaryLabel: {
|
|
437
|
+
kind: "text",
|
|
438
|
+
label: "Rótulo da secundária",
|
|
439
|
+
hint: "Padrão: rótulo da medida no dicionário",
|
|
440
|
+
section: "Aparência"
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
combo: {
|
|
444
|
+
...common(360),
|
|
445
|
+
maxLabels: {
|
|
446
|
+
kind: "integer",
|
|
447
|
+
min: 0,
|
|
448
|
+
max: 1e3,
|
|
449
|
+
default: 24,
|
|
450
|
+
label: "Máx. rótulos",
|
|
451
|
+
hint: "Acima dessa quantidade de barras, os valores aparecem só no tooltip",
|
|
452
|
+
section: "Gráfico"
|
|
453
|
+
},
|
|
454
|
+
dimension: {
|
|
455
|
+
kind: "dimension",
|
|
456
|
+
required: true,
|
|
457
|
+
label: "Dimensão (eixo X)",
|
|
458
|
+
section: "Dados"
|
|
459
|
+
},
|
|
460
|
+
formatDimension,
|
|
461
|
+
bar: {
|
|
462
|
+
kind: "measure",
|
|
463
|
+
required: true,
|
|
464
|
+
label: "Barra",
|
|
465
|
+
section: "Dados"
|
|
466
|
+
},
|
|
467
|
+
line: {
|
|
468
|
+
kind: "measure",
|
|
469
|
+
required: true,
|
|
470
|
+
label: "Linha",
|
|
471
|
+
section: "Dados"
|
|
472
|
+
},
|
|
473
|
+
detail: {
|
|
474
|
+
kind: "measure",
|
|
475
|
+
label: "Detalhe (2ª linha do rótulo)",
|
|
476
|
+
section: "Dados"
|
|
477
|
+
},
|
|
478
|
+
clickToFilter,
|
|
479
|
+
hideEmpty,
|
|
480
|
+
barLabel: {
|
|
481
|
+
kind: "text",
|
|
482
|
+
label: "Rótulo da barra",
|
|
483
|
+
section: "Aparência"
|
|
484
|
+
},
|
|
485
|
+
lineLabel: {
|
|
486
|
+
kind: "text",
|
|
487
|
+
label: "Rótulo da linha",
|
|
488
|
+
section: "Aparência"
|
|
489
|
+
},
|
|
490
|
+
detailLabel: {
|
|
491
|
+
kind: "text",
|
|
492
|
+
label: "Rótulo do detalhe",
|
|
493
|
+
section: "Aparência"
|
|
494
|
+
},
|
|
495
|
+
barColor: {
|
|
496
|
+
kind: "color",
|
|
497
|
+
default: "#F58220",
|
|
498
|
+
label: "Cor da barra",
|
|
499
|
+
section: "Aparência"
|
|
500
|
+
},
|
|
501
|
+
lineColor: {
|
|
502
|
+
kind: "color",
|
|
503
|
+
default: "#555555",
|
|
504
|
+
label: "Cor da linha",
|
|
505
|
+
section: "Aparência"
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
bar: {
|
|
509
|
+
...common(360),
|
|
510
|
+
dimension: {
|
|
511
|
+
kind: "dimension",
|
|
512
|
+
required: true,
|
|
513
|
+
label: "Dimensão (categorias)",
|
|
514
|
+
section: "Dados"
|
|
515
|
+
},
|
|
516
|
+
formatDimension,
|
|
517
|
+
measures: {
|
|
518
|
+
kind: "measures",
|
|
519
|
+
required: true,
|
|
520
|
+
label: "Medidas (uma série cada)",
|
|
521
|
+
section: "Dados"
|
|
522
|
+
},
|
|
523
|
+
sort: {
|
|
524
|
+
kind: "enum",
|
|
525
|
+
values: [
|
|
526
|
+
"dimension",
|
|
527
|
+
"desc",
|
|
528
|
+
"asc"
|
|
529
|
+
],
|
|
530
|
+
labels: {
|
|
531
|
+
dimension: "pela dimensão",
|
|
532
|
+
desc: "maior primeiro",
|
|
533
|
+
asc: "menor primeiro"
|
|
534
|
+
},
|
|
535
|
+
default: "dimension",
|
|
536
|
+
label: "Ordem",
|
|
537
|
+
hint: "Maior/menor primeiro usa a primeira medida",
|
|
538
|
+
section: "Dados"
|
|
539
|
+
},
|
|
540
|
+
limit: {
|
|
541
|
+
kind: "integer",
|
|
542
|
+
min: 1,
|
|
543
|
+
max: 1e3,
|
|
544
|
+
label: "Só os N primeiros",
|
|
545
|
+
hint: "Aplicado depois de ordenar (ex.: top 10 navios)",
|
|
546
|
+
section: "Dados"
|
|
547
|
+
},
|
|
548
|
+
clickToFilter,
|
|
549
|
+
hideEmpty,
|
|
550
|
+
orientation: {
|
|
551
|
+
kind: "enum",
|
|
552
|
+
values: ["vertical", "horizontal"],
|
|
553
|
+
default: "vertical",
|
|
554
|
+
label: "Orientação",
|
|
555
|
+
hint: "Horizontal cabe nomes longos (navios, funções)",
|
|
556
|
+
section: "Aparência"
|
|
557
|
+
},
|
|
558
|
+
stacked: {
|
|
559
|
+
kind: "boolean",
|
|
560
|
+
default: false,
|
|
561
|
+
label: "Empilhar séries",
|
|
562
|
+
hint: "Só faz sentido com medidas somáveis (partes de um total)",
|
|
563
|
+
section: "Aparência"
|
|
564
|
+
},
|
|
565
|
+
showValues: {
|
|
566
|
+
kind: "boolean",
|
|
567
|
+
default: true,
|
|
568
|
+
label: "Mostrar valores",
|
|
569
|
+
section: "Aparência"
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
donut: {
|
|
573
|
+
...common(300),
|
|
574
|
+
dimension: {
|
|
575
|
+
kind: "dimension",
|
|
576
|
+
required: true,
|
|
577
|
+
label: "Dimensão (fatias)",
|
|
578
|
+
section: "Dados"
|
|
579
|
+
},
|
|
580
|
+
formatDimension,
|
|
581
|
+
measure: {
|
|
582
|
+
kind: "measure",
|
|
583
|
+
required: true,
|
|
584
|
+
label: "Medida",
|
|
585
|
+
section: "Dados"
|
|
586
|
+
},
|
|
587
|
+
maxSlices: {
|
|
588
|
+
kind: "integer",
|
|
589
|
+
min: 2,
|
|
590
|
+
max: 8,
|
|
591
|
+
default: 6,
|
|
592
|
+
label: "Máx. fatias",
|
|
593
|
+
hint: "As menores além dessa quantidade viram \"Outros\"",
|
|
594
|
+
section: "Dados"
|
|
595
|
+
},
|
|
596
|
+
clickToFilter
|
|
597
|
+
},
|
|
598
|
+
table: {
|
|
599
|
+
...common(400),
|
|
600
|
+
dimensions: {
|
|
601
|
+
kind: "dimensions",
|
|
602
|
+
label: "Dimensões (linhas)",
|
|
603
|
+
section: "Dados"
|
|
604
|
+
},
|
|
605
|
+
formatDimensions: {
|
|
606
|
+
kind: "formatters",
|
|
607
|
+
label: "Formatar dimensões",
|
|
608
|
+
hint: "Formatters passados ao <DashboardCharts formatters={{ ... }}> da página",
|
|
609
|
+
section: "Dados"
|
|
610
|
+
},
|
|
611
|
+
measures: {
|
|
612
|
+
kind: "measures",
|
|
613
|
+
required: true,
|
|
614
|
+
label: "Medidas (colunas)",
|
|
615
|
+
section: "Dados"
|
|
616
|
+
},
|
|
617
|
+
sortBy: {
|
|
618
|
+
kind: "column",
|
|
619
|
+
label: "Ordenar por",
|
|
620
|
+
hint: "Ordem inicial; quem usa reordena clicando no cabeçalho",
|
|
621
|
+
section: "Dados"
|
|
622
|
+
},
|
|
623
|
+
sortDirection: {
|
|
624
|
+
kind: "enum",
|
|
625
|
+
values: ["desc", "asc"],
|
|
626
|
+
labels: {
|
|
627
|
+
desc: "decrescente",
|
|
628
|
+
asc: "crescente"
|
|
629
|
+
},
|
|
630
|
+
default: "desc",
|
|
631
|
+
label: "Direção",
|
|
632
|
+
section: "Dados"
|
|
633
|
+
},
|
|
634
|
+
showTotal: {
|
|
635
|
+
kind: "boolean",
|
|
636
|
+
default: true,
|
|
637
|
+
label: "Linha de total",
|
|
638
|
+
section: "Dados"
|
|
639
|
+
},
|
|
640
|
+
clickToFilter: {
|
|
641
|
+
...clickToFilter,
|
|
642
|
+
hint: "Clicar numa linha filtra pela primeira dimensão"
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const FILTER_FIELDS = {
|
|
647
|
+
dimension: {
|
|
648
|
+
kind: "dimension",
|
|
649
|
+
required: true,
|
|
650
|
+
label: "Dimensão",
|
|
651
|
+
section: "Filtro"
|
|
652
|
+
},
|
|
653
|
+
label: {
|
|
654
|
+
kind: "text",
|
|
655
|
+
label: "Rótulo",
|
|
656
|
+
hint: "Padrão: rótulo da dimensão no dicionário",
|
|
657
|
+
section: "Filtro"
|
|
658
|
+
},
|
|
659
|
+
width: {
|
|
660
|
+
kind: "integer",
|
|
661
|
+
min: 80,
|
|
662
|
+
max: 800,
|
|
663
|
+
default: 200,
|
|
664
|
+
label: "Largura (px)",
|
|
665
|
+
section: "Filtro"
|
|
666
|
+
},
|
|
667
|
+
formatLabel: {
|
|
668
|
+
kind: "formatter",
|
|
669
|
+
label: "Formatar opções",
|
|
670
|
+
hint: "Formatters passados ao <DashboardFilters formatters={{ ... }}> da página",
|
|
671
|
+
section: "Filtro"
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
const CANVAS_FIELDS = {
|
|
675
|
+
width: {
|
|
676
|
+
kind: "integer",
|
|
677
|
+
min: 320,
|
|
678
|
+
max: 7680,
|
|
679
|
+
required: true,
|
|
680
|
+
label: "Largura (px)",
|
|
681
|
+
section: "Tela"
|
|
682
|
+
},
|
|
683
|
+
height: {
|
|
684
|
+
kind: "integer",
|
|
685
|
+
min: 200,
|
|
686
|
+
max: 4320,
|
|
687
|
+
label: "Altura mínima (px)",
|
|
688
|
+
hint: "Conteúdo mais alto que isso estende a tela",
|
|
689
|
+
section: "Tela"
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
/** Tamanhos comuns de tela para o devtools. */
|
|
693
|
+
const CANVAS_PRESETS = [
|
|
694
|
+
{
|
|
695
|
+
width: 1920,
|
|
696
|
+
height: 1080
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
width: 1600,
|
|
700
|
+
height: 900
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
width: 1366,
|
|
704
|
+
height: 768
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
width: 1280,
|
|
708
|
+
height: 720
|
|
709
|
+
}
|
|
710
|
+
];
|
|
711
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
712
|
+
const isName = (value) => typeof value === "string" && !!value;
|
|
713
|
+
function fieldError(spec, value) {
|
|
714
|
+
if (value === void 0) return spec.required ? "é obrigatório" : null;
|
|
715
|
+
switch (spec.kind) {
|
|
716
|
+
case "text": return typeof value === "string" ? null : "precisa ser texto";
|
|
717
|
+
case "color": return typeof value === "string" && /^#[0-9a-fA-F]{3,8}$/.test(value) ? null : "precisa ser uma cor (#rrggbb)";
|
|
718
|
+
case "boolean": return typeof value === "boolean" ? null : "precisa ser true/false";
|
|
719
|
+
case "integer": return typeof value === "number" && Number.isInteger(value) && value >= spec.min && value <= spec.max ? null : `precisa ser um inteiro de ${spec.min} a ${spec.max}`;
|
|
720
|
+
case "enum": return spec.values.includes(value) ? null : `precisa ser ${spec.values.join(", ")}`;
|
|
721
|
+
case "dimension":
|
|
722
|
+
case "measure":
|
|
723
|
+
case "formatter":
|
|
724
|
+
case "column": return isName(value) ? null : spec.required ? "é obrigatório" : "precisa ser texto";
|
|
725
|
+
case "dimensions":
|
|
726
|
+
case "measures":
|
|
727
|
+
if (!Array.isArray(value) || !value.every(isName)) return "precisa ser uma lista de nomes";
|
|
728
|
+
if (spec.required && !value.length) return "precisa de pelo menos um item";
|
|
729
|
+
return new Set(value).size === value.length ? null : "tem item repetido";
|
|
730
|
+
case "formatters": return isRecord(value) && Object.values(value).every(isName) ? null : "precisa ser { dimensão: formatter }";
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Problemas de forma do layout (vazio = válido). Confere a forma do objeto porque o rascunho do
|
|
735
|
+
* devtools chega como JSON da request.
|
|
736
|
+
*/
|
|
737
|
+
function validateLayout(layout) {
|
|
738
|
+
if (!isRecord(layout)) return ["layout inválido"];
|
|
739
|
+
if (typeof layout.id !== "string" || !LAYOUT_ID.test(layout.id)) return ["`id` do layout: letras, números, \"-\" e \"_\" (até 80)"];
|
|
740
|
+
if (!Array.isArray(layout.charts)) return ["`charts` precisa ser uma lista"];
|
|
741
|
+
const errors = [];
|
|
742
|
+
const ids = /* @__PURE__ */ new Set();
|
|
743
|
+
layout.charts.forEach((chart, index) => {
|
|
744
|
+
const context = `gráfico ${index + 1}`;
|
|
745
|
+
if (!isRecord(chart)) {
|
|
746
|
+
errors.push(`${context} inválido`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (typeof chart.id !== "string" || !LAYOUT_ID.test(chart.id)) errors.push(`${context}: \`id\` inválido`);
|
|
750
|
+
else if (ids.has(chart.id)) errors.push(`${context}: id repetido "${chart.id}"`);
|
|
751
|
+
else ids.add(chart.id);
|
|
752
|
+
const fields = CHART_FIELDS[chart.type];
|
|
753
|
+
if (!CHART_TYPES.includes(chart.type) || !fields) {
|
|
754
|
+
errors.push(`${context}: tipo inválido "${chart.type}"`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (typeof chart.title !== "string") errors.push(`${context}: \`title\` precisa ser texto`);
|
|
758
|
+
const rest = Object.fromEntries(Object.entries(fields).filter(([key]) => key !== "title"));
|
|
759
|
+
errors.push(...fieldsErrors(context, rest, chart, [
|
|
760
|
+
"id",
|
|
761
|
+
"type",
|
|
762
|
+
"title"
|
|
763
|
+
]));
|
|
764
|
+
});
|
|
765
|
+
if (layout.canvas !== void 0) {
|
|
766
|
+
if (!isRecord(layout.canvas)) errors.push("`canvas` precisa ser { width, height? }");
|
|
767
|
+
else errors.push(...fieldsErrors("canvas", CANVAS_FIELDS, layout.canvas));
|
|
768
|
+
}
|
|
769
|
+
if (layout.filters !== void 0) {
|
|
770
|
+
if (!Array.isArray(layout.filters)) return [...errors, "`filters` precisa ser uma lista"];
|
|
771
|
+
const dimensions = /* @__PURE__ */ new Set();
|
|
772
|
+
layout.filters.forEach((filter, index) => {
|
|
773
|
+
const context = `filtro ${index + 1}`;
|
|
774
|
+
if (!isRecord(filter)) {
|
|
775
|
+
errors.push(`${context} inválido`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
errors.push(...fieldsErrors(context, FILTER_FIELDS, filter));
|
|
779
|
+
if (isName(filter.dimension)) {
|
|
780
|
+
if (dimensions.has(filter.dimension)) errors.push(`${context}: dimensão repetida "${filter.dimension}"`);
|
|
781
|
+
dimensions.add(filter.dimension);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return errors;
|
|
786
|
+
}
|
|
787
|
+
function fieldsErrors(context, fields, values, extraKeys = []) {
|
|
788
|
+
const errors = [];
|
|
789
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
790
|
+
const error = fieldError(spec, values[key]);
|
|
791
|
+
if (error) errors.push(`${context}: \`${key}\` ${error}`);
|
|
792
|
+
}
|
|
793
|
+
const known = /* @__PURE__ */ new Set([...extraKeys, ...Object.keys(fields)]);
|
|
794
|
+
for (const key of Object.keys(values)) if (!known.has(key)) errors.push(`${context}: campo desconhecido \`${key}\``);
|
|
795
|
+
return errors;
|
|
796
|
+
}
|
|
797
|
+
/** Dimensões, medidas e formatters que o gráfico referencia (lidos pelos campos do tipo). */
|
|
798
|
+
const chartReferences = (chart) => fieldReferences(CHART_FIELDS[chart.type] ?? {}, chart);
|
|
799
|
+
/** Idem para um filtro da página. */
|
|
800
|
+
const filterReferences = (filter) => fieldReferences(FILTER_FIELDS, filter);
|
|
801
|
+
function fieldReferences(fields, item) {
|
|
802
|
+
const refs = {
|
|
803
|
+
dimensions: [],
|
|
804
|
+
measures: [],
|
|
805
|
+
formatters: []
|
|
806
|
+
};
|
|
807
|
+
const values = item;
|
|
808
|
+
for (const [key, spec] of Object.entries(fields)) {
|
|
809
|
+
const value = values[key];
|
|
810
|
+
if (value === void 0 || value === "") continue;
|
|
811
|
+
if (spec.kind === "dimension") refs.dimensions.push(value);
|
|
812
|
+
if (spec.kind === "dimensions") refs.dimensions.push(...value);
|
|
813
|
+
if (spec.kind === "measure") refs.measures.push(value);
|
|
814
|
+
if (spec.kind === "measures") refs.measures.push(...value);
|
|
815
|
+
if (spec.kind === "formatter") refs.formatters.push(value);
|
|
816
|
+
if (spec.kind === "formatters") refs.formatters.push(...Object.values(value));
|
|
817
|
+
}
|
|
818
|
+
return refs;
|
|
819
|
+
}
|
|
820
|
+
/** Colunas que o gráfico mostra (para `sortBy`): as dimensões e medidas escolhidas nele. */
|
|
821
|
+
function chartColumns(chart) {
|
|
822
|
+
const { dimensions, measures } = chartReferences(chart);
|
|
823
|
+
return [...dimensions, ...measures];
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Dimensões/medidas que o layout usa e o modelo não tem. Não impede salvar (o modelo pode estar
|
|
827
|
+
* sendo editado junto), mas o gráfico vai mostrar erro.
|
|
828
|
+
*/
|
|
829
|
+
function layoutModelWarnings(layout, model) {
|
|
830
|
+
const warnings = [];
|
|
831
|
+
for (const chart of layout.charts) {
|
|
832
|
+
const name = `"${chart.title || chart.id}"`;
|
|
833
|
+
const { dimensions, measures } = chartReferences(chart);
|
|
834
|
+
for (const dimension of dimensions) if (!model.dimensions[dimension]) warnings.push(`${name}: dimensão "${dimension}" não existe`);
|
|
835
|
+
for (const measure of measures) if (!model.measures[measure]) warnings.push(`${name}: medida "${measure}" não existe`);
|
|
836
|
+
if (chart.type === "table" && chart.sortBy && !chartColumns(chart).includes(chart.sortBy)) warnings.push(`${name}: "${chart.sortBy}" (sortBy) não é coluna da tabela`);
|
|
837
|
+
}
|
|
838
|
+
for (const filter of layout.filters ?? []) if (!model.dimensions[filter.dimension]) warnings.push(`filtro "${filter.label || filter.dimension}": dimensão "${filter.dimension}" não existe`);
|
|
839
|
+
return warnings;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Gráfico novo de um tipo, reaproveitando o que der do anterior (troca de tipo no devtools) ou
|
|
843
|
+
* escolhendo dimensão/medidas do modelo.
|
|
844
|
+
*/
|
|
845
|
+
function chartOfType(type, model, base) {
|
|
846
|
+
const { id, title, span } = base;
|
|
847
|
+
const previous = chartReferences(base);
|
|
848
|
+
const measures = Object.entries(model.measures);
|
|
849
|
+
const firstSum = measures.find(([, measure]) => isAggregate(measure))?.[0];
|
|
850
|
+
const firstRatio = measures.find(([, measure]) => measure.type === "ratio")?.[0];
|
|
851
|
+
const dimension = previous.dimensions[0] ?? Object.keys(model.dimensions)[0] ?? "";
|
|
852
|
+
const measure = previous.measures[0] ?? firstSum ?? measures[0]?.[0] ?? "";
|
|
853
|
+
const shared = {
|
|
854
|
+
id,
|
|
855
|
+
title,
|
|
856
|
+
...span !== void 0 && { span }
|
|
857
|
+
};
|
|
858
|
+
switch (type) {
|
|
859
|
+
case "kpi": return {
|
|
860
|
+
...shared,
|
|
861
|
+
type,
|
|
862
|
+
measure,
|
|
863
|
+
...previous.measures[1] && { secondary: previous.measures[1] }
|
|
864
|
+
};
|
|
865
|
+
case "combo": return {
|
|
866
|
+
...shared,
|
|
867
|
+
type,
|
|
868
|
+
dimension,
|
|
869
|
+
bar: measure,
|
|
870
|
+
line: previous.measures[1] ?? firstRatio ?? measure
|
|
871
|
+
};
|
|
872
|
+
case "bar": return {
|
|
873
|
+
...shared,
|
|
874
|
+
type,
|
|
875
|
+
dimension,
|
|
876
|
+
measures: previous.measures.length ? previous.measures : [measure]
|
|
877
|
+
};
|
|
878
|
+
case "donut": return {
|
|
879
|
+
...shared,
|
|
880
|
+
type,
|
|
881
|
+
dimension,
|
|
882
|
+
measure
|
|
883
|
+
};
|
|
884
|
+
case "table": return {
|
|
885
|
+
...shared,
|
|
886
|
+
type,
|
|
887
|
+
dimensions: previous.dimensions.length ? previous.dimensions : [dimension],
|
|
888
|
+
measures: previous.measures.length ? previous.measures : [measure]
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** Conteúdo do arquivo `<id>.json`. */
|
|
893
|
+
function layoutFileContent(layout) {
|
|
894
|
+
return `${JSON.stringify(layout, null, 2)}\n`;
|
|
895
|
+
}
|
|
896
|
+
//#endregion
|
|
897
|
+
export { validateModel as C, parseColumnRef as D, modelEdges as E, reachFrom as O, modelMeta as S, measureHome as T, AGGREGATIONS as _, FILTER_FIELDS as a, aggregateSql as b, chartReferences as c, layoutModelWarnings as d, validateLayout as f, toModelCode as g, modelObjectCode as h, CHART_TYPE_LABEL as i, referencedTables as k, filterReferences as l, modelImports as m, CHART_FIELDS as n, chartColumns as o, canonicalJson as p, CHART_TYPES as r, chartOfType as s, CANVAS_PRESETS as t, layoutFileContent as u, AGGREGATION_LABEL as v, joinPlan as w, isAggregate as x, VALUE_FORMATS as y };
|
|
898
|
+
|
|
899
|
+
//# sourceMappingURL=layout-iPByu6V0.js.map
|