@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/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# cooklang-sankey
|
|
2
|
+
|
|
3
|
+
Generate Sankey diagram data from Cooklang recipes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install cooklang-sankey
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Browser Environment
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { CooklangParser, generateSankeyData, optimizeSankeyData } from 'cooklang-sankey';
|
|
17
|
+
|
|
18
|
+
// Create a parser instance
|
|
19
|
+
const parser = new CooklangParser();
|
|
20
|
+
|
|
21
|
+
// Your Cooklang recipe text
|
|
22
|
+
const recipeText = `
|
|
23
|
+
---
|
|
24
|
+
title: カルボナーラ
|
|
25
|
+
servings: 2
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
@パスタ{200g}を茹でる。
|
|
29
|
+
@卵{2個}と@パルメザンチーズ{50g}を混ぜる。
|
|
30
|
+
@ベーコン{100g}を炒める。
|
|
31
|
+
パスタとソースを合わせて完成。
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
// Parse the recipe text
|
|
35
|
+
const [recipe] = parser.parse(recipeText);
|
|
36
|
+
|
|
37
|
+
// Generate Sankey data from parsed recipe
|
|
38
|
+
const data = generateSankeyData(recipe, {
|
|
39
|
+
finalNodeName: "完成品",
|
|
40
|
+
normalization: "logarithmic",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Optionally optimize the data
|
|
44
|
+
const optimized = data ? optimizeSankeyData(data) : null;
|
|
45
|
+
|
|
46
|
+
console.log(optimized);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Node.js Environment
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { CooklangParser, generateSankeyData, optimizeSankeyData } from 'cooklang-sankey';
|
|
53
|
+
|
|
54
|
+
// Create parser instance
|
|
55
|
+
const parser = new CooklangParser();
|
|
56
|
+
|
|
57
|
+
const recipeText = `
|
|
58
|
+
@パスタ{200g}を茹でる。
|
|
59
|
+
@卵{2個}と@パルメザンチーズ{50g}を混ぜる。
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
// Parse the recipe text
|
|
63
|
+
const [recipe] = parser.parse(recipeText);
|
|
64
|
+
|
|
65
|
+
// Generate Sankey data from parsed recipe
|
|
66
|
+
const data = generateSankeyData(recipe);
|
|
67
|
+
const optimized = data ? optimizeSankeyData(data) : null;
|
|
68
|
+
|
|
69
|
+
console.log(JSON.stringify(optimized, null, 2));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### API
|
|
73
|
+
|
|
74
|
+
#### `generateSankeyData(recipe, options?)`
|
|
75
|
+
|
|
76
|
+
Generates Sankey diagram data from a parsed Cooklang recipe.
|
|
77
|
+
|
|
78
|
+
**Parameters:**
|
|
79
|
+
- `recipe: CooklangRecipe` - Parsed Cooklang recipe object
|
|
80
|
+
- `options?: SankeyGeneratorOptions`
|
|
81
|
+
- `finalNodeName?: string` - Name for the final node (default: "完成品")
|
|
82
|
+
- `normalization?: "logarithmic" | "linear" | "none"` - Value normalization method
|
|
83
|
+
- `minLinkValue?: number` - Minimum link value (default: 0.1)
|
|
84
|
+
|
|
85
|
+
**Returns:** `SankeyData | null`
|
|
86
|
+
|
|
87
|
+
#### `optimizeSankeyData(data)`
|
|
88
|
+
|
|
89
|
+
Validates and optimizes Sankey data by removing invalid links and orphaned nodes.
|
|
90
|
+
|
|
91
|
+
**Returns:** `SankeyData`
|
|
92
|
+
|
|
93
|
+
### Types
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
interface SankeyData {
|
|
97
|
+
nodes: SankeyNode[];
|
|
98
|
+
links: SankeyLink[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface SankeyNode {
|
|
102
|
+
id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
category: "ingredient" | "process" | "final";
|
|
105
|
+
value: number;
|
|
106
|
+
label: string;
|
|
107
|
+
originalValue?: number;
|
|
108
|
+
metadata?: BaseNodeMetadata;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface SankeyLink {
|
|
112
|
+
source: string;
|
|
113
|
+
target: string;
|
|
114
|
+
value: number;
|
|
115
|
+
originalValue?: number;
|
|
116
|
+
metadata?: BaseLinkMetadata;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## React Integration
|
|
121
|
+
|
|
122
|
+
For React applications, we recommend creating custom hooks:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import { useMemo } from 'react';
|
|
126
|
+
import {
|
|
127
|
+
CooklangParser,
|
|
128
|
+
generateSankeyData,
|
|
129
|
+
optimizeSankeyData,
|
|
130
|
+
type CooklangRecipe
|
|
131
|
+
} from 'cooklang-sankey';
|
|
132
|
+
|
|
133
|
+
// Create a singleton parser instance
|
|
134
|
+
const parser = new CooklangParser();
|
|
135
|
+
|
|
136
|
+
export function useSankeyData(recipeText: string) {
|
|
137
|
+
return useMemo(() => {
|
|
138
|
+
if (!recipeText) return null;
|
|
139
|
+
|
|
140
|
+
// Parse the recipe text
|
|
141
|
+
const [recipe] = parser.parse(recipeText);
|
|
142
|
+
|
|
143
|
+
// Generate Sankey data from parsed recipe
|
|
144
|
+
const data = generateSankeyData(recipe);
|
|
145
|
+
return data ? optimizeSankeyData(data) : null;
|
|
146
|
+
}, [recipeText]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Or if you already have a parsed recipe:
|
|
150
|
+
export function useSankeyDataFromRecipe(recipe: CooklangRecipe | null) {
|
|
151
|
+
return useMemo(() => {
|
|
152
|
+
if (!recipe) return null;
|
|
153
|
+
const data = generateSankeyData(recipe);
|
|
154
|
+
return data ? optimizeSankeyData(data) : null;
|
|
155
|
+
}, [recipe]);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var cooklang = require('@cooklang/cooklang');
|
|
4
|
+
var toposort = require('toposort');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var toposort__default = /*#__PURE__*/_interopDefault(toposort);
|
|
9
|
+
|
|
10
|
+
// src/parser.ts
|
|
11
|
+
function extractMetadata(recipe) {
|
|
12
|
+
const result = {};
|
|
13
|
+
if (recipe.title) {
|
|
14
|
+
result.title = recipe.title;
|
|
15
|
+
}
|
|
16
|
+
if (recipe.description) {
|
|
17
|
+
result.description = recipe.description;
|
|
18
|
+
}
|
|
19
|
+
if (recipe.tags && recipe.tags.size > 0) {
|
|
20
|
+
result.tags = Array.from(recipe.tags);
|
|
21
|
+
}
|
|
22
|
+
if (recipe.time) {
|
|
23
|
+
const timeValue = recipe.time;
|
|
24
|
+
if (typeof timeValue === "object" && timeValue !== null) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
if ("prep_time" in timeValue && timeValue.prep_time) {
|
|
27
|
+
parts.push(`prep: ${timeValue.prep_time}`);
|
|
28
|
+
}
|
|
29
|
+
if ("cook_time" in timeValue && timeValue.cook_time) {
|
|
30
|
+
parts.push(`cook: ${timeValue.cook_time}`);
|
|
31
|
+
}
|
|
32
|
+
if ("total_time" in timeValue && timeValue.total_time) {
|
|
33
|
+
parts.push(`total: ${timeValue.total_time}`);
|
|
34
|
+
}
|
|
35
|
+
if (parts.length > 0) {
|
|
36
|
+
result.cookingTime = parts.join(", ");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (recipe.servings) {
|
|
41
|
+
const servings = recipe.servings;
|
|
42
|
+
if (Array.isArray(servings)) {
|
|
43
|
+
result.servings = servings.join("-");
|
|
44
|
+
} else if (typeof servings === "object" && servings !== null) {
|
|
45
|
+
result.servings = String(servings);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (recipe.custom_metadata && recipe.custom_metadata.size > 0) {
|
|
49
|
+
for (const [key, value] of recipe.custom_metadata) {
|
|
50
|
+
if (!(key in result)) {
|
|
51
|
+
result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/constants.ts
|
|
59
|
+
var NORMALIZATION = {
|
|
60
|
+
/** Minimum normalized value for logarithmic scale */
|
|
61
|
+
LOG_MIN: 0.1,
|
|
62
|
+
/** Maximum normalized value for logarithmic scale */
|
|
63
|
+
LOG_MAX: 0.3,
|
|
64
|
+
/** Minimum normalized value for linear scale */
|
|
65
|
+
LINEAR_MIN: 0.1,
|
|
66
|
+
/** Range for linear normalization */
|
|
67
|
+
LINEAR_RANGE: 0.2,
|
|
68
|
+
/** Default value when normalization range is zero */
|
|
69
|
+
DEFAULT_VALUE: 1
|
|
70
|
+
};
|
|
71
|
+
var DEFAULT_FINAL_NODE_NAME = "\u5B8C\u6210\u54C1";
|
|
72
|
+
var DEFAULT_MIN_LINK_VALUE = 0.1;
|
|
73
|
+
var DEFAULT_NORMALIZATION = "logarithmic";
|
|
74
|
+
var FINAL_NODE_ID = "final_dish";
|
|
75
|
+
var DEFAULT_SECTION_NAME = "main";
|
|
76
|
+
|
|
77
|
+
// src/sankey/normalizer.ts
|
|
78
|
+
var normalizeIngredientValues = (ingredientNodes, normalization) => {
|
|
79
|
+
const result = /* @__PURE__ */ new Map();
|
|
80
|
+
if (normalization === "none") {
|
|
81
|
+
ingredientNodes.forEach((node) => {
|
|
82
|
+
result.set(node.id, node.value || NORMALIZATION.DEFAULT_VALUE);
|
|
83
|
+
});
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
const nodeValues = ingredientNodes.map((n) => n.value || NORMALIZATION.DEFAULT_VALUE);
|
|
87
|
+
const minValue = Math.min(...nodeValues);
|
|
88
|
+
const maxValue = Math.max(...nodeValues);
|
|
89
|
+
const valueRange = maxValue - minValue;
|
|
90
|
+
const normalizeValue = (value) => {
|
|
91
|
+
if (valueRange === 0) return NORMALIZATION.DEFAULT_VALUE;
|
|
92
|
+
if (normalization === "logarithmic") {
|
|
93
|
+
const normalizedLinear = (value - minValue) / valueRange;
|
|
94
|
+
const logScale = Math.log10(1 + normalizedLinear * 9);
|
|
95
|
+
return Math.min(Math.max(NORMALIZATION.LOG_MIN, logScale), NORMALIZATION.LOG_MAX);
|
|
96
|
+
} else {
|
|
97
|
+
return NORMALIZATION.LINEAR_MIN + (value - minValue) / valueRange * NORMALIZATION.LINEAR_RANGE;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
ingredientNodes.forEach((node) => {
|
|
101
|
+
result.set(node.id, node.value ? normalizeValue(node.value) : NORMALIZATION.DEFAULT_VALUE);
|
|
102
|
+
});
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
var buildDAGAndSort = (nodes, edges) => {
|
|
106
|
+
const edgePairs = edges.map((edge) => [edge.from, edge.to]);
|
|
107
|
+
try {
|
|
108
|
+
return toposort__default.default(edgePairs);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error("Cycle detected in DAG:", error);
|
|
111
|
+
return nodes.map((node) => node.id);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var calculateNodeValues = (nodes, sortedNodeIds) => {
|
|
115
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
116
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
117
|
+
nodes.forEach((node) => {
|
|
118
|
+
nodeMap.set(node.id, node);
|
|
119
|
+
if (node.category === "ingredient") {
|
|
120
|
+
valueMap.set(node.id, node.value);
|
|
121
|
+
} else {
|
|
122
|
+
valueMap.set(node.id, 0);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
for (const nodeId of sortedNodeIds) {
|
|
126
|
+
const node = nodeMap.get(nodeId);
|
|
127
|
+
if (!node) continue;
|
|
128
|
+
if (node.category === "ingredient") {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let totalInputValue = 0;
|
|
132
|
+
for (const inputId of node.inputs) {
|
|
133
|
+
let inputValue = valueMap.get(inputId) || 0;
|
|
134
|
+
const inputNode = nodeMap.get(inputId);
|
|
135
|
+
if (inputNode && inputNode.outputs.length > 1) {
|
|
136
|
+
inputValue /= inputNode.outputs.length;
|
|
137
|
+
}
|
|
138
|
+
totalInputValue += inputValue;
|
|
139
|
+
}
|
|
140
|
+
valueMap.set(nodeId, totalInputValue);
|
|
141
|
+
}
|
|
142
|
+
return valueMap;
|
|
143
|
+
};
|
|
144
|
+
var formatValue = (value) => {
|
|
145
|
+
if (!value) return "";
|
|
146
|
+
if (value.type === "number") {
|
|
147
|
+
const num = cooklang.getNumericValue(value);
|
|
148
|
+
return num !== null ? num.toString() : "";
|
|
149
|
+
} else if (value.type === "range") {
|
|
150
|
+
const rangeValue = value.value;
|
|
151
|
+
const startNum = cooklang.getNumericValue({ type: "number", value: rangeValue.start });
|
|
152
|
+
const endNum = cooklang.getNumericValue({ type: "number", value: rangeValue.end });
|
|
153
|
+
if (startNum !== null && endNum !== null) {
|
|
154
|
+
return `${startNum}-${endNum}`;
|
|
155
|
+
}
|
|
156
|
+
return "";
|
|
157
|
+
} else if (value.type === "text") {
|
|
158
|
+
return value.value;
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
};
|
|
162
|
+
var formatQuantityAmount = (quantity) => {
|
|
163
|
+
if (!quantity) {
|
|
164
|
+
return { quantity: "", unit: "" };
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
quantity: formatValue(quantity.value),
|
|
168
|
+
unit: quantity.unit || ""
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
var generateStepText = (step, recipe) => {
|
|
172
|
+
return step.items.map((item) => {
|
|
173
|
+
if (item.type === "text") {
|
|
174
|
+
return item.value;
|
|
175
|
+
} else if (item.type === "ingredient") {
|
|
176
|
+
const originalIngredient = recipe.ingredients[item.index];
|
|
177
|
+
if (!originalIngredient) return "";
|
|
178
|
+
const formatted = formatQuantityAmount(originalIngredient.quantity);
|
|
179
|
+
const quantityText = formatted.quantity && formatted.unit ? `(${formatted.quantity}${formatted.unit})` : formatted.quantity ? `(${formatted.quantity})` : "";
|
|
180
|
+
return `${originalIngredient.name}${quantityText}`;
|
|
181
|
+
} else if (item.type === "cookware") {
|
|
182
|
+
return recipe.cookware[item.index]?.name || "";
|
|
183
|
+
} else if (item.type === "timer") {
|
|
184
|
+
const timer = recipe.timers[item.index];
|
|
185
|
+
if (!timer) return "";
|
|
186
|
+
return timer.name || (timer.quantity ? cooklang.quantity_display(timer.quantity) : "");
|
|
187
|
+
}
|
|
188
|
+
return "";
|
|
189
|
+
}).join("");
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// src/sankey/node-builders.ts
|
|
193
|
+
var calculateIngredientQuantity = (groupedQuantity) => {
|
|
194
|
+
if (cooklang.grouped_quantity_is_empty(groupedQuantity)) {
|
|
195
|
+
return { type: "text", label: "unknown" };
|
|
196
|
+
}
|
|
197
|
+
const displayText = cooklang.grouped_quantity_display(groupedQuantity);
|
|
198
|
+
const gq = groupedQuantity;
|
|
199
|
+
const quantityToUse = gq.fixed || gq.scalable || gq.fixed_unknown || gq.scalable_unknown;
|
|
200
|
+
if (quantityToUse) {
|
|
201
|
+
const numericValue = cooklang.getNumericValue(quantityToUse.value);
|
|
202
|
+
if (numericValue !== null) {
|
|
203
|
+
return { type: "number", value: numericValue, label: displayText || `${numericValue}` };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { type: "text", label: displayText || "unknown" };
|
|
207
|
+
};
|
|
208
|
+
var createIngredientNodes = (recipe) => {
|
|
209
|
+
const nodes = [];
|
|
210
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
211
|
+
recipe.groupedIngredients.forEach(([ingredient, groupedQuantity], index) => {
|
|
212
|
+
const extracted = calculateIngredientQuantity(groupedQuantity);
|
|
213
|
+
const value = extracted.type === "number" ? extracted.value : 1;
|
|
214
|
+
const label = extracted.label;
|
|
215
|
+
const ingredientId = index.toString();
|
|
216
|
+
indexMap.set(index, ingredientId);
|
|
217
|
+
nodes.push({
|
|
218
|
+
id: ingredientId,
|
|
219
|
+
name: ingredient.name,
|
|
220
|
+
category: "ingredient",
|
|
221
|
+
value,
|
|
222
|
+
originalValue: value,
|
|
223
|
+
label,
|
|
224
|
+
inputs: [],
|
|
225
|
+
outputs: [],
|
|
226
|
+
metadata: {
|
|
227
|
+
originalIndex: index
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
return { nodes, indexMap };
|
|
232
|
+
};
|
|
233
|
+
var createStepNodes = (recipe) => {
|
|
234
|
+
const nodes = [];
|
|
235
|
+
for (const section of recipe.sections) {
|
|
236
|
+
for (const content of section.content) {
|
|
237
|
+
if (content.type === "step") {
|
|
238
|
+
const step = content.value;
|
|
239
|
+
const stepText = generateStepText(step, recipe);
|
|
240
|
+
const stepId = `step_${section.name || DEFAULT_SECTION_NAME}_${step.number}`;
|
|
241
|
+
nodes.push({
|
|
242
|
+
id: stepId,
|
|
243
|
+
name: stepText || `\u624B\u9806 ${step.number}`,
|
|
244
|
+
category: "process",
|
|
245
|
+
value: 0,
|
|
246
|
+
label: `${step.number}`,
|
|
247
|
+
inputs: [],
|
|
248
|
+
outputs: [],
|
|
249
|
+
metadata: {
|
|
250
|
+
stepNumber: step.number,
|
|
251
|
+
sectionName: section.name || void 0
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return nodes;
|
|
258
|
+
};
|
|
259
|
+
var createFinalNode = (finalNodeName) => ({
|
|
260
|
+
id: FINAL_NODE_ID,
|
|
261
|
+
name: finalNodeName,
|
|
262
|
+
category: "final",
|
|
263
|
+
value: 0,
|
|
264
|
+
label: "",
|
|
265
|
+
inputs: [],
|
|
266
|
+
outputs: []
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// src/sankey/edge-builders.ts
|
|
270
|
+
var getStepId = (sectionName, stepNumber) => {
|
|
271
|
+
return `step_${sectionName || DEFAULT_SECTION_NAME}_${stepNumber}`;
|
|
272
|
+
};
|
|
273
|
+
var extractIngredientUsageFromStep = (step) => {
|
|
274
|
+
const usages = [];
|
|
275
|
+
for (const item of step.items) {
|
|
276
|
+
if (item.type === "ingredient") {
|
|
277
|
+
usages.push({
|
|
278
|
+
ingredientIndex: item.index,
|
|
279
|
+
stepNumber: step.number
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return usages;
|
|
284
|
+
};
|
|
285
|
+
var buildIngredientToStepEdges = (recipe, ingredientIndexMap, nodes) => {
|
|
286
|
+
const edges = [];
|
|
287
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
288
|
+
nodes.forEach((node) => nodeMap.set(node.id, node));
|
|
289
|
+
for (const section of recipe.sections) {
|
|
290
|
+
for (const content of section.content) {
|
|
291
|
+
if (content.type === "step") {
|
|
292
|
+
const step = content.value;
|
|
293
|
+
const currentStepId = getStepId(section.name, step.number);
|
|
294
|
+
const usedIngredients = extractIngredientUsageFromStep(step);
|
|
295
|
+
for (const usage of usedIngredients) {
|
|
296
|
+
const ingredient = recipe.ingredients[usage.ingredientIndex];
|
|
297
|
+
const relation = ingredient.relation;
|
|
298
|
+
if (relation && relation.relation?.type === "reference" && relation.reference_target === "step") {
|
|
299
|
+
const referenceTo = relation.relation.references_to;
|
|
300
|
+
const sourceStepId = getStepId(section.name, referenceTo + 1);
|
|
301
|
+
edges.push({
|
|
302
|
+
from: sourceStepId,
|
|
303
|
+
to: currentStepId,
|
|
304
|
+
metadata: {
|
|
305
|
+
stepNumber: step.number,
|
|
306
|
+
transformationType: "cooking"
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
const sourceNode = nodeMap.get(sourceStepId);
|
|
310
|
+
const targetNode = nodeMap.get(currentStepId);
|
|
311
|
+
if (sourceNode && targetNode) {
|
|
312
|
+
sourceNode.outputs.push(currentStepId);
|
|
313
|
+
targetNode.inputs.push(sourceStepId);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const ingredientId = ingredientIndexMap.get(usage.ingredientIndex);
|
|
318
|
+
if (ingredientId) {
|
|
319
|
+
edges.push({
|
|
320
|
+
from: ingredientId,
|
|
321
|
+
to: currentStepId,
|
|
322
|
+
metadata: {
|
|
323
|
+
stepNumber: step.number,
|
|
324
|
+
transformationType: "preparation"
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const ingredientNode = nodeMap.get(ingredientId);
|
|
328
|
+
const stepNode = nodeMap.get(currentStepId);
|
|
329
|
+
if (ingredientNode && stepNode) {
|
|
330
|
+
ingredientNode.outputs.push(currentStepId);
|
|
331
|
+
stepNode.inputs.push(ingredientId);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return edges;
|
|
339
|
+
};
|
|
340
|
+
var buildStepToFinalEdges = (stepNodes, finalNode) => {
|
|
341
|
+
if (stepNodes.length === 0) return [];
|
|
342
|
+
const lastStepNode = stepNodes[stepNodes.length - 1];
|
|
343
|
+
lastStepNode.outputs.push(finalNode.id);
|
|
344
|
+
finalNode.inputs.push(lastStepNode.id);
|
|
345
|
+
return [
|
|
346
|
+
{
|
|
347
|
+
from: lastStepNode.id,
|
|
348
|
+
to: finalNode.id,
|
|
349
|
+
metadata: {
|
|
350
|
+
transformationType: "completion"
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
];
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// src/sankey/generator.ts
|
|
357
|
+
var DEFAULT_OPTIONS = {
|
|
358
|
+
finalNodeName: DEFAULT_FINAL_NODE_NAME,
|
|
359
|
+
normalization: DEFAULT_NORMALIZATION,
|
|
360
|
+
minLinkValue: DEFAULT_MIN_LINK_VALUE
|
|
361
|
+
};
|
|
362
|
+
var buildSankeyData = (dagNodes, dagEdges, sortedNodeIds, options) => {
|
|
363
|
+
const ingredientNodes = dagNodes.filter((node) => node.category === "ingredient").map((node) => ({
|
|
364
|
+
id: node.id,
|
|
365
|
+
name: node.name,
|
|
366
|
+
category: node.category,
|
|
367
|
+
value: node.value,
|
|
368
|
+
label: node.label,
|
|
369
|
+
originalValue: node.originalValue,
|
|
370
|
+
metadata: node.metadata
|
|
371
|
+
}));
|
|
372
|
+
const normalizedValueMap = normalizeIngredientValues(ingredientNodes, options.normalization);
|
|
373
|
+
const normalizedDAGNodes = dagNodes.map((node) => {
|
|
374
|
+
if (node.category === "ingredient") {
|
|
375
|
+
return {
|
|
376
|
+
...node,
|
|
377
|
+
value: normalizedValueMap.get(node.id) ?? node.value
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return node;
|
|
381
|
+
});
|
|
382
|
+
const finalCalculatedValues = calculateNodeValues(normalizedDAGNodes, sortedNodeIds);
|
|
383
|
+
const sankeyNodes = dagNodes.map((dagNode) => {
|
|
384
|
+
const finalValue = finalCalculatedValues.get(dagNode.id) || 0;
|
|
385
|
+
return {
|
|
386
|
+
id: dagNode.id,
|
|
387
|
+
name: dagNode.name,
|
|
388
|
+
category: dagNode.category,
|
|
389
|
+
value: dagNode.category === "ingredient" ? normalizedValueMap.get(dagNode.id) ?? dagNode.value : finalValue,
|
|
390
|
+
label: dagNode.label,
|
|
391
|
+
originalValue: dagNode.originalValue,
|
|
392
|
+
metadata: dagNode.metadata
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
const sankeyLinks = dagEdges.map((edge) => {
|
|
396
|
+
const sourceValue = finalCalculatedValues.get(edge.from) || 0;
|
|
397
|
+
const sourceNode = dagNodes.find((n) => n.id === edge.from);
|
|
398
|
+
let linkValue = sourceValue;
|
|
399
|
+
if (sourceNode && sourceNode.outputs.length > 1) {
|
|
400
|
+
linkValue = sourceValue / sourceNode.outputs.length;
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
source: edge.from,
|
|
404
|
+
target: edge.to,
|
|
405
|
+
value: Math.max(options.minLinkValue, linkValue),
|
|
406
|
+
originalValue: sourceValue,
|
|
407
|
+
metadata: edge.metadata
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
nodes: sankeyNodes,
|
|
412
|
+
links: sankeyLinks
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
var generateSankeyData = (recipe, options) => {
|
|
416
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
417
|
+
try {
|
|
418
|
+
const { nodes: ingredientNodes, indexMap: ingredientIndexMap } = createIngredientNodes(recipe);
|
|
419
|
+
const stepNodes = createStepNodes(recipe);
|
|
420
|
+
const finalNode = createFinalNode(opts.finalNodeName);
|
|
421
|
+
const allNodes = [...ingredientNodes, ...stepNodes, finalNode];
|
|
422
|
+
const ingredientToStepEdges = buildIngredientToStepEdges(recipe, ingredientIndexMap, allNodes);
|
|
423
|
+
const stepToFinalEdges = buildStepToFinalEdges(stepNodes, finalNode);
|
|
424
|
+
const allEdges = [...ingredientToStepEdges, ...stepToFinalEdges];
|
|
425
|
+
const sortedNodeIds = buildDAGAndSort(allNodes, allEdges);
|
|
426
|
+
return buildSankeyData(allNodes, allEdges, sortedNodeIds, opts);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error("Error generating sankey data from cooklang:", error);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
var optimizeSankeyData = (data) => {
|
|
433
|
+
const existingNodeIds = new Set(data.nodes.map((node) => node.id));
|
|
434
|
+
const validLinks = data.links.filter((link) => {
|
|
435
|
+
const sourceExists = existingNodeIds.has(link.source);
|
|
436
|
+
const targetExists = existingNodeIds.has(link.target);
|
|
437
|
+
if (!sourceExists) {
|
|
438
|
+
console.error(`Missing source node: ${link.source}`);
|
|
439
|
+
}
|
|
440
|
+
if (!targetExists) {
|
|
441
|
+
console.error(`Missing target node: ${link.target}`);
|
|
442
|
+
}
|
|
443
|
+
return sourceExists && targetExists;
|
|
444
|
+
});
|
|
445
|
+
const connectedNodeIds = /* @__PURE__ */ new Set();
|
|
446
|
+
validLinks.forEach((link) => {
|
|
447
|
+
connectedNodeIds.add(link.source);
|
|
448
|
+
connectedNodeIds.add(link.target);
|
|
449
|
+
});
|
|
450
|
+
const filteredNodes = data.nodes.filter(
|
|
451
|
+
(node) => connectedNodeIds.has(node.id) || node.category === "final"
|
|
452
|
+
);
|
|
453
|
+
let normalizedLinks = validLinks;
|
|
454
|
+
if (validLinks.length > 0) {
|
|
455
|
+
const minValue = Math.min(...validLinks.map((link) => link.value));
|
|
456
|
+
if (minValue > 0) {
|
|
457
|
+
normalizedLinks = validLinks.map((link) => ({
|
|
458
|
+
...link,
|
|
459
|
+
value: Math.max(1, link.value / minValue)
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
nodes: filteredNodes,
|
|
465
|
+
links: normalizedLinks
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
Object.defineProperty(exports, "CooklangParser", {
|
|
470
|
+
enumerable: true,
|
|
471
|
+
get: function () { return cooklang.CooklangParser; }
|
|
472
|
+
});
|
|
473
|
+
Object.defineProperty(exports, "CooklangRecipe", {
|
|
474
|
+
enumerable: true,
|
|
475
|
+
get: function () { return cooklang.CooklangRecipe; }
|
|
476
|
+
});
|
|
477
|
+
Object.defineProperty(exports, "getNumericValue", {
|
|
478
|
+
enumerable: true,
|
|
479
|
+
get: function () { return cooklang.getNumericValue; }
|
|
480
|
+
});
|
|
481
|
+
Object.defineProperty(exports, "quantity_display", {
|
|
482
|
+
enumerable: true,
|
|
483
|
+
get: function () { return cooklang.quantity_display; }
|
|
484
|
+
});
|
|
485
|
+
exports.extractMetadata = extractMetadata;
|
|
486
|
+
exports.formatQuantityAmount = formatQuantityAmount;
|
|
487
|
+
exports.formatValue = formatValue;
|
|
488
|
+
exports.generateSankeyData = generateSankeyData;
|
|
489
|
+
exports.generateStepText = generateStepText;
|
|
490
|
+
exports.optimizeSankeyData = optimizeSankeyData;
|
|
491
|
+
//# sourceMappingURL=index.cjs.map
|
|
492
|
+
//# sourceMappingURL=index.cjs.map
|