@4kk11/cooklang-sankey 0.1.0
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 +161 -0
- package/dist/index.cjs +492 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +408 -0
- package/dist/index.d.ts +408 -0
- package/dist/index.js +466 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { getNumericValue, quantity_display, grouped_quantity_is_empty, grouped_quantity_display } from '@cooklang/cooklang';
|
|
2
|
+
export { CooklangParser, CooklangRecipe, getNumericValue, quantity_display } from '@cooklang/cooklang';
|
|
3
|
+
import toposort from 'toposort';
|
|
4
|
+
|
|
5
|
+
// src/parser.ts
|
|
6
|
+
function extractMetadata(recipe) {
|
|
7
|
+
const result = {};
|
|
8
|
+
if (recipe.title) {
|
|
9
|
+
result.title = recipe.title;
|
|
10
|
+
}
|
|
11
|
+
if (recipe.description) {
|
|
12
|
+
result.description = recipe.description;
|
|
13
|
+
}
|
|
14
|
+
if (recipe.tags && recipe.tags.size > 0) {
|
|
15
|
+
result.tags = Array.from(recipe.tags);
|
|
16
|
+
}
|
|
17
|
+
if (recipe.time) {
|
|
18
|
+
const timeValue = recipe.time;
|
|
19
|
+
if (typeof timeValue === "object" && timeValue !== null) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
if ("prep_time" in timeValue && timeValue.prep_time) {
|
|
22
|
+
parts.push(`prep: ${timeValue.prep_time}`);
|
|
23
|
+
}
|
|
24
|
+
if ("cook_time" in timeValue && timeValue.cook_time) {
|
|
25
|
+
parts.push(`cook: ${timeValue.cook_time}`);
|
|
26
|
+
}
|
|
27
|
+
if ("total_time" in timeValue && timeValue.total_time) {
|
|
28
|
+
parts.push(`total: ${timeValue.total_time}`);
|
|
29
|
+
}
|
|
30
|
+
if (parts.length > 0) {
|
|
31
|
+
result.cookingTime = parts.join(", ");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (recipe.servings) {
|
|
36
|
+
const servings = recipe.servings;
|
|
37
|
+
if (Array.isArray(servings)) {
|
|
38
|
+
result.servings = servings.join("-");
|
|
39
|
+
} else if (typeof servings === "object" && servings !== null) {
|
|
40
|
+
result.servings = String(servings);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (recipe.custom_metadata && recipe.custom_metadata.size > 0) {
|
|
44
|
+
for (const [key, value] of recipe.custom_metadata) {
|
|
45
|
+
if (!(key in result)) {
|
|
46
|
+
result[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/constants.ts
|
|
54
|
+
var NORMALIZATION = {
|
|
55
|
+
/** Minimum normalized value for logarithmic scale */
|
|
56
|
+
LOG_MIN: 0.1,
|
|
57
|
+
/** Maximum normalized value for logarithmic scale */
|
|
58
|
+
LOG_MAX: 0.3,
|
|
59
|
+
/** Minimum normalized value for linear scale */
|
|
60
|
+
LINEAR_MIN: 0.1,
|
|
61
|
+
/** Range for linear normalization */
|
|
62
|
+
LINEAR_RANGE: 0.2,
|
|
63
|
+
/** Default value when normalization range is zero */
|
|
64
|
+
DEFAULT_VALUE: 1
|
|
65
|
+
};
|
|
66
|
+
var DEFAULT_FINAL_NODE_NAME = "\u5B8C\u6210\u54C1";
|
|
67
|
+
var DEFAULT_MIN_LINK_VALUE = 0.1;
|
|
68
|
+
var DEFAULT_NORMALIZATION = "logarithmic";
|
|
69
|
+
var FINAL_NODE_ID = "final_dish";
|
|
70
|
+
var DEFAULT_SECTION_NAME = "main";
|
|
71
|
+
|
|
72
|
+
// src/sankey/normalizer.ts
|
|
73
|
+
var normalizeIngredientValues = (ingredientNodes, normalization) => {
|
|
74
|
+
const result = /* @__PURE__ */ new Map();
|
|
75
|
+
if (normalization === "none") {
|
|
76
|
+
ingredientNodes.forEach((node) => {
|
|
77
|
+
result.set(node.id, node.value || NORMALIZATION.DEFAULT_VALUE);
|
|
78
|
+
});
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const nodeValues = ingredientNodes.map((n) => n.value || NORMALIZATION.DEFAULT_VALUE);
|
|
82
|
+
const minValue = Math.min(...nodeValues);
|
|
83
|
+
const maxValue = Math.max(...nodeValues);
|
|
84
|
+
const valueRange = maxValue - minValue;
|
|
85
|
+
const normalizeValue = (value) => {
|
|
86
|
+
if (valueRange === 0) return NORMALIZATION.DEFAULT_VALUE;
|
|
87
|
+
if (normalization === "logarithmic") {
|
|
88
|
+
const normalizedLinear = (value - minValue) / valueRange;
|
|
89
|
+
const logScale = Math.log10(1 + normalizedLinear * 9);
|
|
90
|
+
return Math.min(Math.max(NORMALIZATION.LOG_MIN, logScale), NORMALIZATION.LOG_MAX);
|
|
91
|
+
} else {
|
|
92
|
+
return NORMALIZATION.LINEAR_MIN + (value - minValue) / valueRange * NORMALIZATION.LINEAR_RANGE;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
ingredientNodes.forEach((node) => {
|
|
96
|
+
result.set(node.id, node.value ? normalizeValue(node.value) : NORMALIZATION.DEFAULT_VALUE);
|
|
97
|
+
});
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
var buildDAGAndSort = (nodes, edges) => {
|
|
101
|
+
const edgePairs = edges.map((edge) => [edge.from, edge.to]);
|
|
102
|
+
try {
|
|
103
|
+
return toposort(edgePairs);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("Cycle detected in DAG:", error);
|
|
106
|
+
return nodes.map((node) => node.id);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var calculateNodeValues = (nodes, sortedNodeIds) => {
|
|
110
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
111
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
112
|
+
nodes.forEach((node) => {
|
|
113
|
+
nodeMap.set(node.id, node);
|
|
114
|
+
if (node.category === "ingredient") {
|
|
115
|
+
valueMap.set(node.id, node.value);
|
|
116
|
+
} else {
|
|
117
|
+
valueMap.set(node.id, 0);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
for (const nodeId of sortedNodeIds) {
|
|
121
|
+
const node = nodeMap.get(nodeId);
|
|
122
|
+
if (!node) continue;
|
|
123
|
+
if (node.category === "ingredient") {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let totalInputValue = 0;
|
|
127
|
+
for (const inputId of node.inputs) {
|
|
128
|
+
let inputValue = valueMap.get(inputId) || 0;
|
|
129
|
+
const inputNode = nodeMap.get(inputId);
|
|
130
|
+
if (inputNode && inputNode.outputs.length > 1) {
|
|
131
|
+
inputValue /= inputNode.outputs.length;
|
|
132
|
+
}
|
|
133
|
+
totalInputValue += inputValue;
|
|
134
|
+
}
|
|
135
|
+
valueMap.set(nodeId, totalInputValue);
|
|
136
|
+
}
|
|
137
|
+
return valueMap;
|
|
138
|
+
};
|
|
139
|
+
var formatValue = (value) => {
|
|
140
|
+
if (!value) return "";
|
|
141
|
+
if (value.type === "number") {
|
|
142
|
+
const num = getNumericValue(value);
|
|
143
|
+
return num !== null ? num.toString() : "";
|
|
144
|
+
} else if (value.type === "range") {
|
|
145
|
+
const rangeValue = value.value;
|
|
146
|
+
const startNum = getNumericValue({ type: "number", value: rangeValue.start });
|
|
147
|
+
const endNum = getNumericValue({ type: "number", value: rangeValue.end });
|
|
148
|
+
if (startNum !== null && endNum !== null) {
|
|
149
|
+
return `${startNum}-${endNum}`;
|
|
150
|
+
}
|
|
151
|
+
return "";
|
|
152
|
+
} else if (value.type === "text") {
|
|
153
|
+
return value.value;
|
|
154
|
+
}
|
|
155
|
+
return "";
|
|
156
|
+
};
|
|
157
|
+
var formatQuantityAmount = (quantity) => {
|
|
158
|
+
if (!quantity) {
|
|
159
|
+
return { quantity: "", unit: "" };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
quantity: formatValue(quantity.value),
|
|
163
|
+
unit: quantity.unit || ""
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
var generateStepText = (step, recipe) => {
|
|
167
|
+
return step.items.map((item) => {
|
|
168
|
+
if (item.type === "text") {
|
|
169
|
+
return item.value;
|
|
170
|
+
} else if (item.type === "ingredient") {
|
|
171
|
+
const originalIngredient = recipe.ingredients[item.index];
|
|
172
|
+
if (!originalIngredient) return "";
|
|
173
|
+
const formatted = formatQuantityAmount(originalIngredient.quantity);
|
|
174
|
+
const quantityText = formatted.quantity && formatted.unit ? `(${formatted.quantity}${formatted.unit})` : formatted.quantity ? `(${formatted.quantity})` : "";
|
|
175
|
+
return `${originalIngredient.name}${quantityText}`;
|
|
176
|
+
} else if (item.type === "cookware") {
|
|
177
|
+
return recipe.cookware[item.index]?.name || "";
|
|
178
|
+
} else if (item.type === "timer") {
|
|
179
|
+
const timer = recipe.timers[item.index];
|
|
180
|
+
if (!timer) return "";
|
|
181
|
+
return timer.name || (timer.quantity ? quantity_display(timer.quantity) : "");
|
|
182
|
+
}
|
|
183
|
+
return "";
|
|
184
|
+
}).join("");
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// src/sankey/node-builders.ts
|
|
188
|
+
var calculateIngredientQuantity = (groupedQuantity) => {
|
|
189
|
+
if (grouped_quantity_is_empty(groupedQuantity)) {
|
|
190
|
+
return { type: "text", label: "unknown" };
|
|
191
|
+
}
|
|
192
|
+
const displayText = grouped_quantity_display(groupedQuantity);
|
|
193
|
+
const gq = groupedQuantity;
|
|
194
|
+
const quantityToUse = gq.fixed || gq.scalable || gq.fixed_unknown || gq.scalable_unknown;
|
|
195
|
+
if (quantityToUse) {
|
|
196
|
+
const numericValue = getNumericValue(quantityToUse.value);
|
|
197
|
+
if (numericValue !== null) {
|
|
198
|
+
return { type: "number", value: numericValue, label: displayText || `${numericValue}` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { type: "text", label: displayText || "unknown" };
|
|
202
|
+
};
|
|
203
|
+
var createIngredientNodes = (recipe) => {
|
|
204
|
+
const nodes = [];
|
|
205
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
206
|
+
recipe.groupedIngredients.forEach(([ingredient, groupedQuantity], index) => {
|
|
207
|
+
const extracted = calculateIngredientQuantity(groupedQuantity);
|
|
208
|
+
const value = extracted.type === "number" ? extracted.value : 1;
|
|
209
|
+
const label = extracted.label;
|
|
210
|
+
const ingredientId = index.toString();
|
|
211
|
+
indexMap.set(index, ingredientId);
|
|
212
|
+
nodes.push({
|
|
213
|
+
id: ingredientId,
|
|
214
|
+
name: ingredient.name,
|
|
215
|
+
category: "ingredient",
|
|
216
|
+
value,
|
|
217
|
+
originalValue: value,
|
|
218
|
+
label,
|
|
219
|
+
inputs: [],
|
|
220
|
+
outputs: [],
|
|
221
|
+
metadata: {
|
|
222
|
+
originalIndex: index
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
return { nodes, indexMap };
|
|
227
|
+
};
|
|
228
|
+
var createStepNodes = (recipe) => {
|
|
229
|
+
const nodes = [];
|
|
230
|
+
for (const section of recipe.sections) {
|
|
231
|
+
for (const content of section.content) {
|
|
232
|
+
if (content.type === "step") {
|
|
233
|
+
const step = content.value;
|
|
234
|
+
const stepText = generateStepText(step, recipe);
|
|
235
|
+
const stepId = `step_${section.name || DEFAULT_SECTION_NAME}_${step.number}`;
|
|
236
|
+
nodes.push({
|
|
237
|
+
id: stepId,
|
|
238
|
+
name: stepText || `\u624B\u9806 ${step.number}`,
|
|
239
|
+
category: "process",
|
|
240
|
+
value: 0,
|
|
241
|
+
label: `${step.number}`,
|
|
242
|
+
inputs: [],
|
|
243
|
+
outputs: [],
|
|
244
|
+
metadata: {
|
|
245
|
+
stepNumber: step.number,
|
|
246
|
+
sectionName: section.name || void 0
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return nodes;
|
|
253
|
+
};
|
|
254
|
+
var createFinalNode = (finalNodeName) => ({
|
|
255
|
+
id: FINAL_NODE_ID,
|
|
256
|
+
name: finalNodeName,
|
|
257
|
+
category: "final",
|
|
258
|
+
value: 0,
|
|
259
|
+
label: "",
|
|
260
|
+
inputs: [],
|
|
261
|
+
outputs: []
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// src/sankey/edge-builders.ts
|
|
265
|
+
var getStepId = (sectionName, stepNumber) => {
|
|
266
|
+
return `step_${sectionName || DEFAULT_SECTION_NAME}_${stepNumber}`;
|
|
267
|
+
};
|
|
268
|
+
var extractIngredientUsageFromStep = (step) => {
|
|
269
|
+
const usages = [];
|
|
270
|
+
for (const item of step.items) {
|
|
271
|
+
if (item.type === "ingredient") {
|
|
272
|
+
usages.push({
|
|
273
|
+
ingredientIndex: item.index,
|
|
274
|
+
stepNumber: step.number
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return usages;
|
|
279
|
+
};
|
|
280
|
+
var buildIngredientToStepEdges = (recipe, ingredientIndexMap, nodes) => {
|
|
281
|
+
const edges = [];
|
|
282
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
283
|
+
nodes.forEach((node) => nodeMap.set(node.id, node));
|
|
284
|
+
for (const section of recipe.sections) {
|
|
285
|
+
for (const content of section.content) {
|
|
286
|
+
if (content.type === "step") {
|
|
287
|
+
const step = content.value;
|
|
288
|
+
const currentStepId = getStepId(section.name, step.number);
|
|
289
|
+
const usedIngredients = extractIngredientUsageFromStep(step);
|
|
290
|
+
for (const usage of usedIngredients) {
|
|
291
|
+
const ingredient = recipe.ingredients[usage.ingredientIndex];
|
|
292
|
+
const relation = ingredient.relation;
|
|
293
|
+
if (relation && relation.relation?.type === "reference" && relation.reference_target === "step") {
|
|
294
|
+
const referenceTo = relation.relation.references_to;
|
|
295
|
+
const sourceStepId = getStepId(section.name, referenceTo + 1);
|
|
296
|
+
edges.push({
|
|
297
|
+
from: sourceStepId,
|
|
298
|
+
to: currentStepId,
|
|
299
|
+
metadata: {
|
|
300
|
+
stepNumber: step.number,
|
|
301
|
+
transformationType: "cooking"
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const sourceNode = nodeMap.get(sourceStepId);
|
|
305
|
+
const targetNode = nodeMap.get(currentStepId);
|
|
306
|
+
if (sourceNode && targetNode) {
|
|
307
|
+
sourceNode.outputs.push(currentStepId);
|
|
308
|
+
targetNode.inputs.push(sourceStepId);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const ingredientId = ingredientIndexMap.get(usage.ingredientIndex);
|
|
313
|
+
if (ingredientId) {
|
|
314
|
+
edges.push({
|
|
315
|
+
from: ingredientId,
|
|
316
|
+
to: currentStepId,
|
|
317
|
+
metadata: {
|
|
318
|
+
stepNumber: step.number,
|
|
319
|
+
transformationType: "preparation"
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const ingredientNode = nodeMap.get(ingredientId);
|
|
323
|
+
const stepNode = nodeMap.get(currentStepId);
|
|
324
|
+
if (ingredientNode && stepNode) {
|
|
325
|
+
ingredientNode.outputs.push(currentStepId);
|
|
326
|
+
stepNode.inputs.push(ingredientId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return edges;
|
|
334
|
+
};
|
|
335
|
+
var buildStepToFinalEdges = (stepNodes, finalNode) => {
|
|
336
|
+
if (stepNodes.length === 0) return [];
|
|
337
|
+
const lastStepNode = stepNodes[stepNodes.length - 1];
|
|
338
|
+
lastStepNode.outputs.push(finalNode.id);
|
|
339
|
+
finalNode.inputs.push(lastStepNode.id);
|
|
340
|
+
return [
|
|
341
|
+
{
|
|
342
|
+
from: lastStepNode.id,
|
|
343
|
+
to: finalNode.id,
|
|
344
|
+
metadata: {
|
|
345
|
+
transformationType: "completion"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
];
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// src/sankey/generator.ts
|
|
352
|
+
var DEFAULT_OPTIONS = {
|
|
353
|
+
finalNodeName: DEFAULT_FINAL_NODE_NAME,
|
|
354
|
+
normalization: DEFAULT_NORMALIZATION,
|
|
355
|
+
minLinkValue: DEFAULT_MIN_LINK_VALUE
|
|
356
|
+
};
|
|
357
|
+
var buildSankeyData = (dagNodes, dagEdges, sortedNodeIds, options) => {
|
|
358
|
+
const ingredientNodes = dagNodes.filter((node) => node.category === "ingredient").map((node) => ({
|
|
359
|
+
id: node.id,
|
|
360
|
+
name: node.name,
|
|
361
|
+
category: node.category,
|
|
362
|
+
value: node.value,
|
|
363
|
+
label: node.label,
|
|
364
|
+
originalValue: node.originalValue,
|
|
365
|
+
metadata: node.metadata
|
|
366
|
+
}));
|
|
367
|
+
const normalizedValueMap = normalizeIngredientValues(ingredientNodes, options.normalization);
|
|
368
|
+
const normalizedDAGNodes = dagNodes.map((node) => {
|
|
369
|
+
if (node.category === "ingredient") {
|
|
370
|
+
return {
|
|
371
|
+
...node,
|
|
372
|
+
value: normalizedValueMap.get(node.id) ?? node.value
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return node;
|
|
376
|
+
});
|
|
377
|
+
const finalCalculatedValues = calculateNodeValues(normalizedDAGNodes, sortedNodeIds);
|
|
378
|
+
const sankeyNodes = dagNodes.map((dagNode) => {
|
|
379
|
+
const finalValue = finalCalculatedValues.get(dagNode.id) || 0;
|
|
380
|
+
return {
|
|
381
|
+
id: dagNode.id,
|
|
382
|
+
name: dagNode.name,
|
|
383
|
+
category: dagNode.category,
|
|
384
|
+
value: dagNode.category === "ingredient" ? normalizedValueMap.get(dagNode.id) ?? dagNode.value : finalValue,
|
|
385
|
+
label: dagNode.label,
|
|
386
|
+
originalValue: dagNode.originalValue,
|
|
387
|
+
metadata: dagNode.metadata
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
const sankeyLinks = dagEdges.map((edge) => {
|
|
391
|
+
const sourceValue = finalCalculatedValues.get(edge.from) || 0;
|
|
392
|
+
const sourceNode = dagNodes.find((n) => n.id === edge.from);
|
|
393
|
+
let linkValue = sourceValue;
|
|
394
|
+
if (sourceNode && sourceNode.outputs.length > 1) {
|
|
395
|
+
linkValue = sourceValue / sourceNode.outputs.length;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
source: edge.from,
|
|
399
|
+
target: edge.to,
|
|
400
|
+
value: Math.max(options.minLinkValue, linkValue),
|
|
401
|
+
originalValue: sourceValue,
|
|
402
|
+
metadata: edge.metadata
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
nodes: sankeyNodes,
|
|
407
|
+
links: sankeyLinks
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
var generateSankeyData = (recipe, options) => {
|
|
411
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
412
|
+
try {
|
|
413
|
+
const { nodes: ingredientNodes, indexMap: ingredientIndexMap } = createIngredientNodes(recipe);
|
|
414
|
+
const stepNodes = createStepNodes(recipe);
|
|
415
|
+
const finalNode = createFinalNode(opts.finalNodeName);
|
|
416
|
+
const allNodes = [...ingredientNodes, ...stepNodes, finalNode];
|
|
417
|
+
const ingredientToStepEdges = buildIngredientToStepEdges(recipe, ingredientIndexMap, allNodes);
|
|
418
|
+
const stepToFinalEdges = buildStepToFinalEdges(stepNodes, finalNode);
|
|
419
|
+
const allEdges = [...ingredientToStepEdges, ...stepToFinalEdges];
|
|
420
|
+
const sortedNodeIds = buildDAGAndSort(allNodes, allEdges);
|
|
421
|
+
return buildSankeyData(allNodes, allEdges, sortedNodeIds, opts);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
console.error("Error generating sankey data from cooklang:", error);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var optimizeSankeyData = (data) => {
|
|
428
|
+
const existingNodeIds = new Set(data.nodes.map((node) => node.id));
|
|
429
|
+
const validLinks = data.links.filter((link) => {
|
|
430
|
+
const sourceExists = existingNodeIds.has(link.source);
|
|
431
|
+
const targetExists = existingNodeIds.has(link.target);
|
|
432
|
+
if (!sourceExists) {
|
|
433
|
+
console.error(`Missing source node: ${link.source}`);
|
|
434
|
+
}
|
|
435
|
+
if (!targetExists) {
|
|
436
|
+
console.error(`Missing target node: ${link.target}`);
|
|
437
|
+
}
|
|
438
|
+
return sourceExists && targetExists;
|
|
439
|
+
});
|
|
440
|
+
const connectedNodeIds = /* @__PURE__ */ new Set();
|
|
441
|
+
validLinks.forEach((link) => {
|
|
442
|
+
connectedNodeIds.add(link.source);
|
|
443
|
+
connectedNodeIds.add(link.target);
|
|
444
|
+
});
|
|
445
|
+
const filteredNodes = data.nodes.filter(
|
|
446
|
+
(node) => connectedNodeIds.has(node.id) || node.category === "final"
|
|
447
|
+
);
|
|
448
|
+
let normalizedLinks = validLinks;
|
|
449
|
+
if (validLinks.length > 0) {
|
|
450
|
+
const minValue = Math.min(...validLinks.map((link) => link.value));
|
|
451
|
+
if (minValue > 0) {
|
|
452
|
+
normalizedLinks = validLinks.map((link) => ({
|
|
453
|
+
...link,
|
|
454
|
+
value: Math.max(1, link.value / minValue)
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
nodes: filteredNodes,
|
|
460
|
+
links: normalizedLinks
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
export { extractMetadata, formatQuantityAmount, formatValue, generateSankeyData, generateStepText, optimizeSankeyData };
|
|
465
|
+
//# sourceMappingURL=index.js.map
|
|
466
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/parser.ts","../src/constants.ts","../src/sankey/normalizer.ts","../src/sankey/dag.ts","../src/formatter.ts","../src/sankey/node-builders.ts","../src/sankey/edge-builders.ts","../src/sankey/generator.ts"],"names":["getNumericValue"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Cooklang Parser wrapper using official @cooklang/cooklang package.\n *\n * @remarks\n * This module provides a thin wrapper around the official Cooklang parser,\n * re-exporting types and adding metadata extraction utilities.\n *\n * @packageDocumentation\n */\n\nimport {\n CooklangParser,\n CooklangRecipe,\n type Ingredient,\n type Quantity,\n type Section,\n type Step,\n type Content,\n type Item,\n type Value,\n} from \"@cooklang/cooklang\";\n\n// Re-export types for convenience\nexport type {\n CooklangParser,\n CooklangRecipe,\n Ingredient,\n Quantity,\n Section,\n Step,\n Content,\n Item,\n Value,\n};\n\n/**\n * Metadata extracted from a parsed Cooklang recipe.\n *\n * @remarks\n * Contains standard recipe metadata fields plus any custom metadata\n * defined in the recipe using the `>> key: value` syntax.\n */\nexport interface RecipeMetadata {\n /** Recipe title from `>> title:` */\n title?: string;\n /** Recipe description from `>> description:` */\n description?: string;\n /** Array of tags from `>> tags:` */\n tags?: string[];\n /** Formatted cooking time string (e.g., \"prep: 10min, cook: 30min\") */\n cookingTime?: string;\n /** Servings range or value (e.g., \"4\" or \"4-6\") */\n servings?: string;\n /** Additional custom metadata fields */\n [key: string]: unknown;\n}\n\n/**\n * Extracts metadata from a parsed Cooklang recipe.\n *\n * @param recipe - The parsed CooklangRecipe object\n * @returns An object containing extracted metadata fields\n *\n * @example\n * ```ts\n * import { CooklangParser } from \"@cooklang/cooklang\";\n * import { extractMetadata } from \"./parser\";\n *\n * const parser = new CooklangParser();\n * const [recipe] = parser.parse(`\n * >> title: Pasta Carbonara\n * >> servings: 4\n * @pasta{400g} を茹でる\n * `);\n *\n * const metadata = extractMetadata(recipe);\n * // { title: \"Pasta Carbonara\", servings: \"4\" }\n * ```\n */\nexport function extractMetadata(recipe: CooklangRecipe): RecipeMetadata {\n const result: RecipeMetadata = {};\n\n if (recipe.title) {\n result.title = recipe.title;\n }\n\n if (recipe.description) {\n result.description = recipe.description;\n }\n\n if (recipe.tags && recipe.tags.size > 0) {\n result.tags = Array.from(recipe.tags);\n }\n\n if (recipe.time) {\n // Format time object to string\n const timeValue = recipe.time;\n if (typeof timeValue === \"object\" && timeValue !== null) {\n const parts: string[] = [];\n if (\"prep_time\" in timeValue && timeValue.prep_time) {\n parts.push(`prep: ${timeValue.prep_time}`);\n }\n if (\"cook_time\" in timeValue && timeValue.cook_time) {\n parts.push(`cook: ${timeValue.cook_time}`);\n }\n if (\"total_time\" in timeValue && timeValue.total_time) {\n parts.push(`total: ${timeValue.total_time}`);\n }\n if (parts.length > 0) {\n result.cookingTime = parts.join(\", \");\n }\n }\n }\n\n if (recipe.servings) {\n const servings = recipe.servings;\n if (Array.isArray(servings)) {\n result.servings = servings.join(\"-\");\n } else if (typeof servings === \"object\" && servings !== null) {\n result.servings = String(servings);\n }\n }\n\n // Add custom metadata\n if (recipe.custom_metadata && recipe.custom_metadata.size > 0) {\n for (const [key, value] of recipe.custom_metadata) {\n if (!(key in result)) {\n result[key] = value;\n }\n }\n }\n\n return result;\n}\n","/**\n * Constants for Sankey diagram generation\n */\n\n/**\n * Normalization constants for value scaling\n */\nexport const NORMALIZATION = {\n /** Minimum normalized value for logarithmic scale */\n LOG_MIN: 0.1,\n /** Maximum normalized value for logarithmic scale */\n LOG_MAX: 0.3,\n /** Minimum normalized value for linear scale */\n LINEAR_MIN: 0.1,\n /** Range for linear normalization */\n LINEAR_RANGE: 0.2,\n /** Default value when normalization range is zero */\n DEFAULT_VALUE: 1,\n} as const;\n\n/**\n * Default options for Sankey generation\n */\nexport const DEFAULT_FINAL_NODE_NAME = \"完成品\";\nexport const DEFAULT_MIN_LINK_VALUE = 0.1;\nexport const DEFAULT_NORMALIZATION = \"logarithmic\" as const;\n\n/**\n * Node identifiers\n */\nexport const FINAL_NODE_ID = \"final_dish\";\nexport const DEFAULT_SECTION_NAME = \"main\";\n","/**\n * Value normalization utilities for Sankey diagram nodes.\n *\n * @remarks\n * Provides normalization functions to scale ingredient values for\n * balanced Sankey diagram visualization. Supports logarithmic, linear,\n * and no normalization modes.\n *\n * @packageDocumentation\n */\n\nimport { NORMALIZATION } from \"../constants\";\nimport type { SankeyNode } from \"../types/sankey\";\n\n/**\n * Normalizes ingredient node values using the specified method.\n *\n * @remarks\n * Returns a Map for O(1) lookup of normalized values by node ID.\n * - **logarithmic**: Compresses large value ranges using log10 scale\n * - **linear**: Scales values linearly within a fixed range\n * - **none**: Returns original values unchanged\n *\n * @param ingredientNodes - Array of ingredient nodes to normalize\n * @param normalization - The normalization method to apply\n * @returns A Map of node ID to normalized value\n *\n * @example\n * ```ts\n * const nodes: SankeyNode[] = [\n * { id: \"0\", name: \"flour\", value: 500, ... },\n * { id: \"1\", name: \"salt\", value: 5, ... }\n * ];\n *\n * const normalized = normalizeIngredientValues(nodes, \"logarithmic\");\n * // Map { \"0\" => 0.3, \"1\" => 0.1 }\n * ```\n */\nexport const normalizeIngredientValues = (\n ingredientNodes: SankeyNode[],\n normalization: \"logarithmic\" | \"linear\" | \"none\",\n): Map<string, number> => {\n const result = new Map<string, number>();\n\n if (normalization === \"none\") {\n ingredientNodes.forEach((node) => {\n result.set(node.id, node.value || NORMALIZATION.DEFAULT_VALUE);\n });\n return result;\n }\n\n const nodeValues = ingredientNodes.map((n) => n.value || NORMALIZATION.DEFAULT_VALUE);\n const minValue = Math.min(...nodeValues);\n const maxValue = Math.max(...nodeValues);\n const valueRange = maxValue - minValue;\n\n const normalizeValue = (value: number): number => {\n if (valueRange === 0) return NORMALIZATION.DEFAULT_VALUE;\n\n if (normalization === \"logarithmic\") {\n const normalizedLinear = (value - minValue) / valueRange;\n const logScale = Math.log10(1 + normalizedLinear * 9);\n return Math.min(Math.max(NORMALIZATION.LOG_MIN, logScale), NORMALIZATION.LOG_MAX);\n } else {\n return (\n NORMALIZATION.LINEAR_MIN + ((value - minValue) / valueRange) * NORMALIZATION.LINEAR_RANGE\n );\n }\n };\n\n ingredientNodes.forEach((node) => {\n result.set(node.id, node.value ? normalizeValue(node.value) : NORMALIZATION.DEFAULT_VALUE);\n });\n\n return result;\n};\n","/**\n * DAG (Directed Acyclic Graph) operations for Sankey diagram generation.\n *\n * @remarks\n * Provides topological sorting and value propagation algorithms\n * for computing flow values through the recipe graph.\n *\n * @packageDocumentation\n */\n\nimport toposort from \"toposort\";\nimport type { DAGNode, DAGEdge } from \"../types/sankey\";\n\n/**\n * Builds a DAG from nodes and edges, returning topologically sorted node IDs.\n *\n * @remarks\n * Uses Kahn's algorithm via the `toposort` library. If a cycle is detected,\n * falls back to returning node IDs in their original order.\n *\n * @param nodes - Array of DAG nodes\n * @param edges - Array of DAG edges defining dependencies\n * @returns Array of node IDs in topological order (dependencies before dependents)\n *\n * @example\n * ```ts\n * const nodes = [ingredientNode, stepNode, finalNode];\n * const edges = [\n * { from: ingredientNode.id, to: stepNode.id },\n * { from: stepNode.id, to: finalNode.id }\n * ];\n *\n * const sorted = buildDAGAndSort(nodes, edges);\n * // [ingredientNode.id, stepNode.id, finalNode.id]\n * ```\n */\nexport const buildDAGAndSort = (nodes: DAGNode[], edges: DAGEdge[]): string[] => {\n const edgePairs: Array<[string, string]> = edges.map((edge) => [edge.from, edge.to]);\n\n try {\n return toposort(edgePairs);\n } catch (error) {\n console.error(\"Cycle detected in DAG:\", error);\n return nodes.map((node) => node.id);\n }\n};\n\n/**\n * Calculates node values by propagating flow through the DAG.\n *\n * @remarks\n * Ingredient nodes retain their original values. Process and final nodes\n * accumulate values from their inputs. When a node has multiple outputs,\n * its value is split equally among them.\n *\n * @param nodes - Array of DAG nodes with input/output connections\n * @param sortedNodeIds - Node IDs in topological order\n * @returns A Map of node ID to calculated flow value\n *\n * @example\n * ```ts\n * // Given: flour(500) -> step1 -> final\n * // salt(5) -> step1\n * const values = calculateNodeValues(nodes, sortedIds);\n * // Map { \"flour\" => 500, \"salt\" => 5, \"step1\" => 505, \"final\" => 505 }\n * ```\n */\nexport const calculateNodeValues = (\n nodes: DAGNode[],\n sortedNodeIds: string[],\n): Map<string, number> => {\n const nodeMap = new Map<string, DAGNode>();\n const valueMap = new Map<string, number>();\n\n nodes.forEach((node) => {\n nodeMap.set(node.id, node);\n if (node.category === \"ingredient\") {\n valueMap.set(node.id, node.value);\n } else {\n valueMap.set(node.id, 0);\n }\n });\n\n for (const nodeId of sortedNodeIds) {\n const node = nodeMap.get(nodeId);\n if (!node) continue;\n\n if (node.category === \"ingredient\") {\n continue;\n }\n\n let totalInputValue = 0;\n for (const inputId of node.inputs) {\n let inputValue = valueMap.get(inputId) || 0;\n const inputNode = nodeMap.get(inputId);\n if (inputNode && inputNode.outputs.length > 1) {\n inputValue /= inputNode.outputs.length;\n }\n totalInputValue += inputValue;\n }\n\n valueMap.set(nodeId, totalInputValue);\n }\n\n return valueMap;\n};\n","/**\n * Value formatting utilities using official @cooklang/cooklang package.\n *\n * @remarks\n * Provides utilities for converting Cooklang quantity and value types\n * into human-readable string representations.\n *\n * @packageDocumentation\n */\n\nimport {\n getNumericValue,\n quantity_display,\n type CooklangRecipe,\n type Value,\n type Quantity,\n type Step,\n} from \"@cooklang/cooklang\";\n\n/**\n * Formats a Cooklang Value to a string representation.\n *\n * @param value - The Value object to format (number, range, or text)\n * @returns A string representation of the value, or empty string if null/undefined\n *\n * @example\n * ```ts\n * formatValue({ type: \"number\", value: 200 }); // \"200\"\n * formatValue({ type: \"range\", value: { start: 2, end: 3 } }); // \"2-3\"\n * formatValue({ type: \"text\", value: \"some\" }); // \"some\"\n * formatValue(null); // \"\"\n * ```\n */\nexport const formatValue = (value: Value | null | undefined): string => {\n if (!value) return \"\";\n\n if (value.type === \"number\") {\n const num = getNumericValue(value);\n return num !== null ? num.toString() : \"\";\n } else if (value.type === \"range\") {\n // Range structure: { start, end }\n const rangeValue = value.value as { start: unknown; end: unknown };\n const startNum = getNumericValue({ type: \"number\", value: rangeValue.start } as Value);\n const endNum = getNumericValue({ type: \"number\", value: rangeValue.end } as Value);\n if (startNum !== null && endNum !== null) {\n return `${startNum}-${endNum}`;\n }\n return \"\";\n } else if (value.type === \"text\") {\n return value.value as string;\n }\n return \"\";\n};\n\n/**\n * Formats a Cooklang Quantity into separate value and unit strings.\n *\n * @param quantity - The Quantity object to format\n * @returns An object with `quantity` (numeric string) and `unit` (unit string)\n *\n * @example\n * ```ts\n * formatQuantityAmount({ value: { type: \"number\", value: 200 }, unit: \"g\" });\n * // { quantity: \"200\", unit: \"g\" }\n *\n * formatQuantityAmount(null);\n * // { quantity: \"\", unit: \"\" }\n * ```\n */\nexport const formatQuantityAmount = (\n quantity: Quantity | null | undefined,\n): { quantity: string; unit: string } => {\n if (!quantity) {\n return { quantity: \"\", unit: \"\" };\n }\n return {\n quantity: formatValue(quantity.value),\n unit: quantity.unit || \"\",\n };\n};\n\n/**\n * Generates complete text from step items by resolving references.\n *\n * @remarks\n * Converts a Step's items array into a readable string by:\n * - Keeping text items as-is\n * - Resolving ingredient references to \"name(quantity)\" format\n * - Resolving cookware references to their names\n * - Resolving timer references to their display values\n *\n * @param step - The Step object containing items to format\n * @param recipe - The parent CooklangRecipe for resolving references\n * @returns A concatenated string of all step items\n *\n * @example\n * ```ts\n * // For a step with text \"Cook \" + ingredient(pasta, 400g) + \" until done\"\n * generateStepText(step, recipe);\n * // \"Cook pasta(400g) until done\"\n * ```\n */\nexport const generateStepText = (step: Step, recipe: CooklangRecipe): string => {\n return step.items\n .map((item) => {\n if (item.type === \"text\") {\n return item.value;\n } else if (item.type === \"ingredient\") {\n const originalIngredient = recipe.ingredients[item.index];\n if (!originalIngredient) return \"\";\n\n const formatted = formatQuantityAmount(originalIngredient.quantity);\n const quantityText =\n formatted.quantity && formatted.unit\n ? `(${formatted.quantity}${formatted.unit})`\n : formatted.quantity\n ? `(${formatted.quantity})`\n : \"\";\n\n return `${originalIngredient.name}${quantityText}`;\n } else if (item.type === \"cookware\") {\n return recipe.cookware[item.index]?.name || \"\";\n } else if (item.type === \"timer\") {\n const timer = recipe.timers[item.index];\n if (!timer) return \"\";\n return timer.name || (timer.quantity ? quantity_display(timer.quantity) : \"\");\n }\n return \"\";\n })\n .join(\"\");\n};\n\n// Re-export useful functions from official package\nexport { getNumericValue, quantity_display };\n","/**\n * Node builders for Sankey diagram generation.\n *\n * @remarks\n * Factory functions for creating DAG nodes from Cooklang recipe elements.\n * Handles ingredients, process steps, and the final dish node.\n *\n * @packageDocumentation\n */\n\nimport {\n type CooklangRecipe,\n getNumericValue,\n grouped_quantity_display,\n grouped_quantity_is_empty,\n type Quantity,\n} from \"@cooklang/cooklang\";\nimport { generateStepText } from \"../formatter\";\nimport { DEFAULT_SECTION_NAME, FINAL_NODE_ID } from \"../constants\";\nimport type { DAGNode, NodeCategory, ExtractedValue } from \"../types/sankey\";\n\n/**\n * Extracts a numeric or text value from a Cooklang GroupedQuantity.\n *\n * @remarks\n * Attempts to extract a numeric value from fixed, scalable, or unknown\n * quantity types. Falls back to text representation if no numeric value\n * is available.\n *\n * @param groupedQuantity - The GroupedQuantity object from Cooklang parser\n * @returns An ExtractedValue with either numeric value or text label\n *\n * @example\n * ```ts\n * // For \"200g\" quantity\n * calculateIngredientQuantity(gq);\n * // { type: \"number\", value: 200, label: \"200g\" }\n *\n * // For \"some\" quantity\n * calculateIngredientQuantity(gq);\n * // { type: \"text\", label: \"some\" }\n * ```\n */\nexport const calculateIngredientQuantity = (\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n groupedQuantity: any,\n): ExtractedValue => {\n if (grouped_quantity_is_empty(groupedQuantity)) {\n return { type: \"text\", label: \"unknown\" };\n }\n\n const displayText = grouped_quantity_display(groupedQuantity);\n\n const gq = groupedQuantity as {\n fixed?: Quantity | null;\n scalable?: Quantity | null;\n fixed_unknown?: Quantity | null;\n scalable_unknown?: Quantity | null;\n };\n\n const quantityToUse = gq.fixed || gq.scalable || gq.fixed_unknown || gq.scalable_unknown;\n\n if (quantityToUse) {\n const numericValue = getNumericValue(quantityToUse.value);\n if (numericValue !== null) {\n return { type: \"number\", value: numericValue, label: displayText || `${numericValue}` };\n }\n }\n\n return { type: \"text\", label: displayText || \"unknown\" };\n};\n\n/**\n * Creates DAG nodes for all ingredients in a recipe.\n *\n * @remarks\n * Processes the recipe's grouped ingredients to create nodes with:\n * - Unique IDs based on ingredient index\n * - Extracted quantity values for flow calculation\n * - Display labels with quantity information\n *\n * @param recipe - The parsed CooklangRecipe\n * @returns An object containing:\n * - `nodes`: Array of ingredient DAGNodes\n * - `indexMap`: Map from original ingredient index to node ID\n *\n * @example\n * ```ts\n * const { nodes, indexMap } = createIngredientNodes(recipe);\n * // nodes: [{ id: \"0\", name: \"flour\", value: 500, ... }, ...]\n * // indexMap: Map { 0 => \"0\", 1 => \"1\", ... }\n * ```\n */\nexport const createIngredientNodes = (\n recipe: CooklangRecipe,\n): { nodes: DAGNode[]; indexMap: Map<number, string> } => {\n const nodes: DAGNode[] = [];\n const indexMap = new Map<number, string>();\n\n recipe.groupedIngredients.forEach(([ingredient, groupedQuantity], index) => {\n const extracted = calculateIngredientQuantity(groupedQuantity);\n const value = extracted.type === \"number\" ? extracted.value : 1;\n const label = extracted.label;\n const ingredientId = index.toString();\n\n indexMap.set(index, ingredientId);\n\n nodes.push({\n id: ingredientId,\n name: ingredient.name,\n category: \"ingredient\" as NodeCategory,\n value,\n originalValue: value,\n label,\n inputs: [],\n outputs: [],\n metadata: {\n originalIndex: index,\n },\n });\n });\n\n return { nodes, indexMap };\n};\n\n/**\n * Creates DAG nodes for all cooking steps in a recipe.\n *\n * @remarks\n * Iterates through all sections and steps, creating process nodes with:\n * - Unique IDs based on section name and step number\n * - Generated text from step items (ingredients, cookware, timers)\n * - Initial value of 0 (calculated later via flow propagation)\n *\n * @param recipe - The parsed CooklangRecipe\n * @returns Array of process DAGNodes representing cooking steps\n *\n * @example\n * ```ts\n * const stepNodes = createStepNodes(recipe);\n * // [{ id: \"step_main_1\", name: \"Boil pasta(400g)\", category: \"process\", ... }]\n * ```\n */\nexport const createStepNodes = (recipe: CooklangRecipe): DAGNode[] => {\n const nodes: DAGNode[] = [];\n\n for (const section of recipe.sections) {\n for (const content of section.content) {\n if (content.type === \"step\") {\n const step = content.value;\n const stepText = generateStepText(step, recipe);\n const stepId = `step_${section.name || DEFAULT_SECTION_NAME}_${step.number}`;\n\n nodes.push({\n id: stepId,\n name: stepText || `手順 ${step.number}`,\n category: \"process\" as NodeCategory,\n value: 0,\n label: `${step.number}`,\n inputs: [],\n outputs: [],\n metadata: {\n stepNumber: step.number,\n sectionName: section.name || undefined,\n },\n });\n }\n }\n }\n\n return nodes;\n};\n\n/**\n * Creates the final node representing the completed dish.\n *\n * @remarks\n * The final node is the sink of the Sankey diagram where all\n * process flows converge. Its value is calculated by summing\n * all incoming flows.\n *\n * @param finalNodeName - Display name for the final dish node\n * @returns A DAGNode with category \"final\"\n *\n * @example\n * ```ts\n * const finalNode = createFinalNode(\"Carbonara\");\n * // { id: \"final_dish\", name: \"Carbonara\", category: \"final\", ... }\n * ```\n */\nexport const createFinalNode = (finalNodeName: string): DAGNode => ({\n id: FINAL_NODE_ID,\n name: finalNodeName,\n category: \"final\" as NodeCategory,\n value: 0,\n label: \"\",\n inputs: [],\n outputs: [],\n});\n","/**\n * Edge builders for Sankey diagram generation.\n *\n * @remarks\n * Factory functions for creating DAG edges that connect nodes.\n * Handles ingredient-to-step and step-to-final connections,\n * including step reference resolution.\n *\n * @packageDocumentation\n */\n\nimport { type CooklangRecipe, type Step } from \"@cooklang/cooklang\";\nimport { DEFAULT_SECTION_NAME } from \"../constants\";\nimport type { DAGNode, DAGEdge, TransformationType } from \"../types/sankey\";\n\n/**\n * Generates a unique step ID from section name and step number.\n *\n * @param sectionName - The recipe section name (null uses default)\n * @param stepNumber - The 1-indexed step number\n * @returns A unique step ID string (e.g., \"step_main_1\")\n *\n * @example\n * ```ts\n * getStepId(\"main\", 1); // \"step_main_1\"\n * getStepId(null, 2); // \"step_main_2\"\n * getStepId(\"sauce\", 1); // \"step_sauce_1\"\n * ```\n */\nexport const getStepId = (sectionName: string | null | undefined, stepNumber: number): string => {\n return `step_${sectionName || DEFAULT_SECTION_NAME}_${stepNumber}`;\n};\n\n/**\n * Extracts ingredient usage information from a recipe step.\n *\n * @param step - The Step object to analyze\n * @returns Array of objects containing ingredient index and step number\n *\n * @example\n * ```ts\n * // For a step using @pasta and @salt\n * extractIngredientUsageFromStep(step);\n * // [{ ingredientIndex: 0, stepNumber: 1 }, { ingredientIndex: 1, stepNumber: 1 }]\n * ```\n */\nexport const extractIngredientUsageFromStep = (\n step: Step,\n): Array<{ ingredientIndex: number; stepNumber: number }> => {\n const usages: Array<{ ingredientIndex: number; stepNumber: number }> = [];\n\n for (const item of step.items) {\n if (item.type === \"ingredient\") {\n usages.push({\n ingredientIndex: item.index,\n stepNumber: step.number,\n });\n }\n }\n\n return usages;\n};\n\n/**\n * Builds edges connecting ingredients to their consuming steps.\n *\n * @remarks\n * Analyzes the recipe to create edges from:\n * - Ingredient nodes to step nodes (for direct ingredient usage)\n * - Step nodes to other step nodes (for step references like \"the pasta from step 1\")\n *\n * Also updates the input/output arrays on the nodes for DAG traversal.\n *\n * @param recipe - The parsed CooklangRecipe\n * @param ingredientIndexMap - Map from ingredient index to node ID\n * @param nodes - Array of all DAG nodes (modified in place)\n * @returns Array of DAGEdges connecting ingredients/steps to steps\n *\n * @example\n * ```ts\n * const edges = buildIngredientToStepEdges(recipe, indexMap, nodes);\n * // [{ from: \"0\", to: \"step_main_1\", metadata: { ... } }, ...]\n * ```\n */\nexport const buildIngredientToStepEdges = (\n recipe: CooklangRecipe,\n ingredientIndexMap: Map<number, string>,\n nodes: DAGNode[],\n): DAGEdge[] => {\n const edges: DAGEdge[] = [];\n const nodeMap = new Map<string, DAGNode>();\n\n nodes.forEach((node) => nodeMap.set(node.id, node));\n\n for (const section of recipe.sections) {\n for (const content of section.content) {\n if (content.type === \"step\") {\n const step = content.value;\n const currentStepId = getStepId(section.name, step.number);\n const usedIngredients = extractIngredientUsageFromStep(step);\n\n for (const usage of usedIngredients) {\n const ingredient = recipe.ingredients[usage.ingredientIndex];\n\n // Check for step reference\n const relation = ingredient.relation;\n if (\n relation &&\n relation.relation?.type === \"reference\" &&\n relation.reference_target === \"step\"\n ) {\n const referenceTo = relation.relation.references_to;\n const sourceStepId = getStepId(section.name, referenceTo + 1);\n edges.push({\n from: sourceStepId,\n to: currentStepId,\n metadata: {\n stepNumber: step.number,\n transformationType: \"cooking\" as TransformationType,\n },\n });\n\n const sourceNode = nodeMap.get(sourceStepId);\n const targetNode = nodeMap.get(currentStepId);\n if (sourceNode && targetNode) {\n sourceNode.outputs.push(currentStepId);\n targetNode.inputs.push(sourceStepId);\n }\n continue;\n }\n\n const ingredientId = ingredientIndexMap.get(usage.ingredientIndex);\n if (ingredientId) {\n edges.push({\n from: ingredientId,\n to: currentStepId,\n metadata: {\n stepNumber: step.number,\n transformationType: \"preparation\" as TransformationType,\n },\n });\n\n const ingredientNode = nodeMap.get(ingredientId);\n const stepNode = nodeMap.get(currentStepId);\n if (ingredientNode && stepNode) {\n ingredientNode.outputs.push(currentStepId);\n stepNode.inputs.push(ingredientId);\n }\n }\n }\n }\n }\n }\n\n return edges;\n};\n\n/**\n * Builds an edge from the last step to the final dish node.\n *\n * @remarks\n * Connects only the last step to the final node, representing\n * the completion of the recipe. Also updates the input/output\n * arrays on the affected nodes.\n *\n * @param stepNodes - Array of step nodes in order\n * @param finalNode - The final dish node (modified in place)\n * @returns Array containing a single edge to the final node, or empty if no steps\n *\n * @example\n * ```ts\n * const edges = buildStepToFinalEdges(stepNodes, finalNode);\n * // [{ from: \"step_main_3\", to: \"final_dish\", metadata: { transformationType: \"completion\" } }]\n * ```\n */\nexport const buildStepToFinalEdges = (stepNodes: DAGNode[], finalNode: DAGNode): DAGEdge[] => {\n if (stepNodes.length === 0) return [];\n\n const lastStepNode = stepNodes[stepNodes.length - 1];\n\n lastStepNode.outputs.push(finalNode.id);\n finalNode.inputs.push(lastStepNode.id);\n\n return [\n {\n from: lastStepNode.id,\n to: finalNode.id,\n metadata: {\n transformationType: \"completion\" as TransformationType,\n },\n },\n ];\n};\n","/**\n * Sankey diagram data generator from Cooklang recipes.\n *\n * @remarks\n * Main module for transforming parsed Cooklang recipes into\n * Sankey diagram data structures. Orchestrates the DAG building,\n * value calculation, and normalization pipeline.\n *\n * @packageDocumentation\n */\n\nimport type { CooklangRecipe } from \"@cooklang/cooklang\";\nimport {\n DEFAULT_FINAL_NODE_NAME,\n DEFAULT_MIN_LINK_VALUE,\n DEFAULT_NORMALIZATION,\n} from \"../constants\";\nimport type { SankeyNode, SankeyLink, SankeyData, DAGNode, DAGEdge } from \"../types/sankey\";\nimport { normalizeIngredientValues } from \"./normalizer\";\nimport { buildDAGAndSort, calculateNodeValues } from \"./dag\";\nimport { createIngredientNodes, createStepNodes, createFinalNode } from \"./node-builders\";\nimport { buildIngredientToStepEdges, buildStepToFinalEdges } from \"./edge-builders\";\n\n/**\n * Configuration options for Sankey diagram generation.\n *\n * @example\n * ```ts\n * const options: SankeyGeneratorOptions = {\n * finalNodeName: \"Carbonara\",\n * normalization: \"logarithmic\",\n * minLinkValue: 0.1\n * };\n * ```\n */\nexport interface SankeyGeneratorOptions {\n /**\n * Display name for the final dish node.\n * @defaultValue \"完成品\"\n */\n finalNodeName?: string;\n /**\n * Method for normalizing ingredient values.\n * - `logarithmic`: Compress large ranges (recommended)\n * - `linear`: Linear scaling\n * - `none`: Use raw values\n * @defaultValue \"logarithmic\"\n */\n normalization?: \"logarithmic\" | \"linear\" | \"none\";\n /**\n * Minimum value for links to ensure visibility.\n * @defaultValue 0.1\n */\n minLinkValue?: number;\n}\n\nconst DEFAULT_OPTIONS: Required<SankeyGeneratorOptions> = {\n finalNodeName: DEFAULT_FINAL_NODE_NAME,\n normalization: DEFAULT_NORMALIZATION,\n minLinkValue: DEFAULT_MIN_LINK_VALUE,\n};\n\n/**\n * Transforms DAG nodes and edges into final Sankey diagram data.\n *\n * @remarks\n * Internal function that:\n * 1. Normalizes ingredient values using the specified method\n * 2. Calculates flow values for all nodes via DAG propagation\n * 3. Converts DAG structures to Sankey-compatible format\n * 4. Applies minimum value constraints to links\n *\n * @param dagNodes - Array of DAG nodes with dependency info\n * @param dagEdges - Array of DAG edges\n * @param sortedNodeIds - Node IDs in topological order\n * @param options - Generation options with all defaults applied\n * @returns Complete SankeyData structure ready for visualization\n *\n * @internal\n */\nconst buildSankeyData = (\n dagNodes: DAGNode[],\n dagEdges: DAGEdge[],\n sortedNodeIds: string[],\n options: Required<SankeyGeneratorOptions>,\n): SankeyData => {\n const ingredientNodes = dagNodes\n .filter((node) => node.category === \"ingredient\")\n .map((node) => ({\n id: node.id,\n name: node.name,\n category: node.category,\n value: node.value,\n label: node.label,\n originalValue: node.originalValue,\n metadata: node.metadata,\n }));\n\n const normalizedValueMap = normalizeIngredientValues(ingredientNodes, options.normalization);\n\n const normalizedDAGNodes = dagNodes.map((node) => {\n if (node.category === \"ingredient\") {\n return {\n ...node,\n value: normalizedValueMap.get(node.id) ?? node.value,\n };\n }\n return node;\n });\n\n const finalCalculatedValues = calculateNodeValues(normalizedDAGNodes, sortedNodeIds);\n\n const sankeyNodes: SankeyNode[] = dagNodes.map((dagNode) => {\n const finalValue = finalCalculatedValues.get(dagNode.id) || 0;\n return {\n id: dagNode.id,\n name: dagNode.name,\n category: dagNode.category,\n value:\n dagNode.category === \"ingredient\"\n ? (normalizedValueMap.get(dagNode.id) ?? dagNode.value)\n : finalValue,\n label: dagNode.label,\n originalValue: dagNode.originalValue,\n metadata: dagNode.metadata,\n };\n });\n\n const sankeyLinks: SankeyLink[] = dagEdges.map((edge) => {\n const sourceValue = finalCalculatedValues.get(edge.from) || 0;\n const sourceNode = dagNodes.find((n) => n.id === edge.from);\n\n let linkValue = sourceValue;\n if (sourceNode && sourceNode.outputs.length > 1) {\n linkValue = sourceValue / sourceNode.outputs.length;\n }\n\n return {\n source: edge.from,\n target: edge.to,\n value: Math.max(options.minLinkValue, linkValue),\n originalValue: sourceValue,\n metadata: edge.metadata,\n };\n });\n\n return {\n nodes: sankeyNodes,\n links: sankeyLinks,\n };\n};\n\n/**\n * Generates Sankey diagram data from a parsed Cooklang recipe.\n *\n * @remarks\n * This is the main entry point for the library. It transforms a parsed\n * Cooklang recipe into a Sankey diagram data structure by:\n * 1. Creating nodes for ingredients, steps, and the final dish\n * 2. Building edges representing ingredient flow\n * 3. Performing topological sort for correct value propagation\n * 4. Normalizing values for balanced visualization\n *\n * @param recipe - A parsed CooklangRecipe object\n * @param options - Optional configuration for generation\n * @returns SankeyData structure, or null if generation fails\n *\n * @example\n * ```ts\n * import { CooklangParser, generateSankeyData } from 'cooklang-sankey';\n *\n * const parser = new CooklangParser();\n * const [recipe] = parser.parse(`\n * @pasta{400g} を茹でる。\n * @卵{3個}と@チーズ{100g}を混ぜる。\n * `);\n *\n * const data = generateSankeyData(recipe, {\n * finalNodeName: \"Carbonara\",\n * normalization: \"logarithmic\"\n * });\n * ```\n */\nexport const generateSankeyData = (\n recipe: CooklangRecipe,\n options?: SankeyGeneratorOptions,\n): SankeyData | null => {\n const opts = { ...DEFAULT_OPTIONS, ...options };\n\n try {\n const { nodes: ingredientNodes, indexMap: ingredientIndexMap } = createIngredientNodes(recipe);\n\n const stepNodes = createStepNodes(recipe);\n const finalNode = createFinalNode(opts.finalNodeName);\n\n const allNodes = [...ingredientNodes, ...stepNodes, finalNode];\n\n const ingredientToStepEdges = buildIngredientToStepEdges(recipe, ingredientIndexMap, allNodes);\n const stepToFinalEdges = buildStepToFinalEdges(stepNodes, finalNode);\n const allEdges = [...ingredientToStepEdges, ...stepToFinalEdges];\n\n const sortedNodeIds = buildDAGAndSort(allNodes, allEdges);\n\n return buildSankeyData(allNodes, allEdges, sortedNodeIds, opts);\n } catch (error) {\n console.error(\"Error generating sankey data from cooklang:\", error);\n return null;\n }\n};\n\n/**\n * Validates and optimizes Sankey data for visualization.\n *\n * @remarks\n * Performs three optimization steps:\n * 1. **Remove invalid links**: Filters out links referencing non-existent nodes\n * 2. **Remove orphaned nodes**: Keeps only nodes connected to valid links\n * (final nodes are always preserved)\n * 3. **Normalize link values**: Scales values relative to minimum for\n * stable visualization (ensures all values >= 1)\n *\n * @param data - The SankeyData to optimize\n * @returns A new SankeyData with optimizations applied\n *\n * @example\n * ```ts\n * const rawData = generateSankeyData(recipe);\n * if (rawData) {\n * const optimized = optimizeSankeyData(rawData);\n * // Use optimized data for visualization\n * }\n * ```\n */\nexport const optimizeSankeyData = (data: SankeyData): SankeyData => {\n // Create a Set of existing node IDs for O(1) lookup\n const existingNodeIds = new Set(data.nodes.map((node) => node.id));\n\n // Step 1: Remove invalid links\n // Filter out links where source or target node doesn't exist\n const validLinks = data.links.filter((link) => {\n const sourceExists = existingNodeIds.has(link.source);\n const targetExists = existingNodeIds.has(link.target);\n\n if (!sourceExists) {\n console.error(`Missing source node: ${link.source}`);\n }\n if (!targetExists) {\n console.error(`Missing target node: ${link.target}`);\n }\n\n return sourceExists && targetExists;\n });\n\n // Step 2: Remove orphaned nodes\n // Collect node IDs that are connected to valid links\n const connectedNodeIds = new Set<string>();\n validLinks.forEach((link) => {\n connectedNodeIds.add(link.source);\n connectedNodeIds.add(link.target);\n });\n\n // Keep only nodes connected to links, or final nodes (always preserved)\n const filteredNodes = data.nodes.filter(\n (node) => connectedNodeIds.has(node.id) || node.category === \"final\",\n );\n\n // Step 3: Normalize link values\n // Scale values relative to minimum for stable visualization\n let normalizedLinks = validLinks;\n if (validLinks.length > 0) {\n const minValue = Math.min(...validLinks.map((link) => link.value));\n if (minValue > 0) {\n // Divide by min value and clamp to ensure value >= 1\n normalizedLinks = validLinks.map((link) => ({\n ...link,\n value: Math.max(1, link.value / minValue),\n }));\n }\n }\n\n return {\n nodes: filteredNodes,\n links: normalizedLinks,\n };\n};\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@4kk11/cooklang-sankey",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate Sankey diagram data from Cooklang recipes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"require": {
|
|
13
|
+
"types": "./dist/index.d.cts",
|
|
14
|
+
"default": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"clean": "rm -rf dist",
|
|
30
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
31
|
+
"format:check": "prettier --check \"src/**/*.ts\""
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@cooklang/cooklang": "^0.17.2",
|
|
35
|
+
"toposort": "^2.0.2"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^25.0.3",
|
|
39
|
+
"@types/toposort": "^2.0.7",
|
|
40
|
+
"prettier": "^3.7.4",
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"tsx": "^4.21.0",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"cooklang",
|
|
47
|
+
"sankey",
|
|
48
|
+
"diagram",
|
|
49
|
+
"recipe",
|
|
50
|
+
"visualization"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": ""
|
|
59
|
+
}
|
|
60
|
+
}
|