@yarkivaev/scada 2.3.87 → 2.3.88

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 CHANGED
@@ -4,13 +4,16 @@ Generic industrial monitoring domain, state persistence, and HTTP APIs (`@yarkiv
4
4
 
5
5
  This package is plant-agnostic: it defines `plant` → `shop` → `machine` hierarchy, timeline and alerts, sensor read ports, STOMP ingest pipelines, and PostgreSQL/ClickHouse adapters. Plant-specific routes, sensors, HMI strings, and tag taxonomies live in downstream plant packages.
6
6
 
7
- `analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic) and read ports. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
7
+ `analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic), read ports, and the tag catalog already used by `plantApi({ tagCatalog })`. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each SQL engine runs one query at a time. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
8
+
9
+ Resources: `plant://topology`, `plant://bindings`, `plant://tags`, `plant://schema/postgres`, `plant://schema/clickhouse`. Column lists live in the schema resources, not here.
8
10
 
9
11
  ```javascript
10
- import { analyticsMcp, namespaceTree } from '@yarkivaev/scada';
12
+ import { analyticsMcp, namespaceTree, tagCatalog } from '@yarkivaev/scada';
11
13
 
12
14
  const mcp = analyticsMcp({
13
15
  tree: namespaceTree(nodes),
16
+ catalog: tagCatalog(),
14
17
  tokens: { secret: 'analyst' },
15
18
  journal(line) { process.stdout.write(line); },
16
19
  ports: {
@@ -21,7 +24,7 @@ const mcp = analyticsMcp({
21
24
  await mcp.listen(0, '127.0.0.1');
22
25
  ```
23
26
 
24
- `ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`.
27
+ `ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`. Coverage spans add the overlapping timeline segment name as `phase`. The process journal is stdout only; `activityTracking` still writes Loki HTTP access into `activity.http_access`, not `activity.mcp_calls`.
25
28
 
26
29
  Repository: `https://github.com/yarkivaev/scada`
27
30
 
@@ -132,7 +135,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
132
135
  | `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
133
136
  | `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
134
137
  | `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
135
- | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, checkpoint-or-sample cursor, read-only SQL guard |
138
+ | `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageOverlay`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, timeline-name overlay, checkpoint-or-sample cursor, read-only SQL guard |
136
139
 
137
140
  Subpath exports:
138
141
 
package/index.js CHANGED
@@ -87,6 +87,7 @@ export { default as siteSyncSites } from './src/application/sync/siteSyncSites.j
87
87
  export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
88
88
  export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
89
89
  export { default as namespaceTree } from './src/domain/namespace/namespaceTree.js';
90
+ export { default as coverageOverlay } from './src/domain/span/coverageOverlay.js';
90
91
  export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
91
92
  export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
92
93
  export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.87",
3
+ "version": "2.3.88",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { createRequire } from 'node:module';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import mcpFlight from './mcpFlight.js';
5
6
  import mcpMetrics from './mcpMetrics.js';
6
7
  import observe from './mcpObserve.js';
7
8
  import registerCoverage from './mcpCoverage.js';
@@ -98,20 +99,25 @@ function extendServer(server, bundle) {
98
99
  * Builds one stateless server for a single HTTP request.
99
100
  *
100
101
  * @param {object} bundle - analyticsMcp bundle
101
- * @param {object} metrics - prometheus counters
102
+ * @param {object} runtime - metrics and SQL flight
102
103
  * @param {string} caller - bearer caller name
103
104
  * @returns {object} McpServer
104
105
  *
105
106
  * @example
106
- * openServer(bundle, metrics, 'analyst');
107
+ * openServer(bundle, runtime, 'analyst');
107
108
  */
108
- function openServer(bundle, metrics, caller) {
109
+ function openServer(bundle, runtime, caller) {
109
110
  const server = new McpServer(serverInfo());
110
- observe(server, { caller, journal: bundle.journal, metrics });
111
+ observe(server, {
112
+ caller,
113
+ flight: runtime.flight,
114
+ journal: bundle.journal,
115
+ metrics: runtime.metrics
116
+ });
111
117
  registerReads(server, bundle.ports, bundle.tree);
112
118
  registerCoverage(server, bundle.ports, bundle.tree);
113
119
  registerQueries(server, bundle.ports);
114
- registerResources(server, bundle.tree);
120
+ registerResources(server, bundle.tree, bundle.catalog);
115
121
  extendServer(server, bundle);
116
122
  return server;
117
123
  }
@@ -183,8 +189,8 @@ function releaser(transport, server) {
183
189
  * @example
184
190
  * dispatch(req, res, bundle, metrics, 'analyst');
185
191
  */
186
- async function dispatch(req, res, bundle, metrics, caller) {
187
- const server = openServer(bundle, metrics, caller);
192
+ async function dispatch(req, res, bundle, runtime, caller) {
193
+ const server = openServer(bundle, runtime, caller);
188
194
  const transport = new StreamableHTTPServerTransport({
189
195
  enableJsonResponse: true,
190
196
  sessionIdGenerator: undefined
@@ -214,13 +220,13 @@ async function dispatch(req, res, bundle, metrics, caller) {
214
220
  * @example
215
221
  * postMcp(req, res, bundle, metrics);
216
222
  */
217
- async function postMcp(req, res, bundle, metrics) {
223
+ async function postMcp(req, res, bundle, runtime) {
218
224
  const name = callerName(req, bundle.tokens);
219
225
  if (name.length === 0) {
220
226
  res.writeHead(401).end();
221
227
  return;
222
228
  }
223
- await dispatch(req, res, bundle, metrics, name);
229
+ await dispatch(req, res, bundle, runtime, name);
224
230
  }
225
231
 
226
232
  /**
@@ -233,9 +239,9 @@ async function postMcp(req, res, bundle, metrics) {
233
239
  * @example
234
240
  * writeMetrics(res, metrics);
235
241
  */
236
- function writeMetrics(res, metrics) {
242
+ function writeMetrics(res, runtime) {
237
243
  res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
238
- res.end(metrics.text());
244
+ res.end(runtime.metrics.text());
239
245
  }
240
246
 
241
247
  /**
@@ -250,13 +256,13 @@ function writeMetrics(res, metrics) {
250
256
  * @example
251
257
  * route(req, res, bundle, metrics);
252
258
  */
253
- function route(req, res, bundle, metrics) {
259
+ function route(req, res, bundle, runtime) {
254
260
  if (req.method === 'GET' && req.url === '/metrics') {
255
- writeMetrics(res, metrics);
261
+ writeMetrics(res, runtime);
256
262
  return Promise.resolve();
257
263
  }
258
264
  if (req.method === 'POST' && req.url === '/mcp') {
259
- return postMcp(req, res, bundle, metrics);
265
+ return postMcp(req, res, bundle, runtime);
260
266
  }
261
267
  res.writeHead(404).end();
262
268
  return Promise.resolve();
@@ -291,8 +297,8 @@ function fail(res) {
291
297
  * @example
292
298
  * onRequest(req, res, bundle, metrics);
293
299
  */
294
- function onRequest(req, res, bundle, metrics) {
295
- route(req, res, bundle, metrics).catch(() => {
300
+ function onRequest(req, res, bundle, runtime) {
301
+ route(req, res, bundle, runtime).catch(() => {
296
302
  fail(res);
297
303
  });
298
304
  }
@@ -352,18 +358,18 @@ function shut(server) {
352
358
  * is a function. extend(server) may register more tools. POST /mcp speaks MCP.
353
359
  * GET /metrics is Prometheus text.
354
360
  *
355
- * @param {object} bundle - tree, tokens, journal, ports, optional extend
361
+ * @param {object} bundle - tree, tokens, journal, ports, optional catalog and extend
356
362
  * @returns {object} listen and close
357
363
  *
358
364
  * @example
359
- * const mcp = analyticsMcp({ tree, tokens, journal, ports });
365
+ * const mcp = analyticsMcp({ tree, tokens, journal, ports, catalog });
360
366
  * await mcp.listen(0, '127.0.0.1');
361
367
  */
362
368
  export default function analyticsMcp(bundle) {
363
369
  assertBundle(bundle);
364
- const metrics = mcpMetrics();
370
+ const runtime = { flight: mcpFlight(), metrics: mcpMetrics() };
365
371
  const server = http.createServer((req, res) => {
366
- onRequest(req, res, bundle, metrics);
372
+ onRequest(req, res, bundle, runtime);
367
373
  });
368
374
  return Object.freeze({
369
375
  close() {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import coverageOverlay from '../../domain/span/coverageOverlay.js';
2
3
  import coverageSpans from '../../domain/span/coverageSpans.js';
3
4
  import { toolText } from './mcpText.js';
4
5
 
@@ -34,13 +35,13 @@ async function coverOne(path, args, ports, tree) {
34
35
  const node = tree.resolve(path);
35
36
  const samples = await ports.samples({ from: args.from, path, to: args.to });
36
37
  const cursor = await ports.cursor({ path });
37
- const spans = coverageSpans(
38
+ const spans = coverageOverlay(coverageSpans(
38
39
  samples,
39
40
  args.from,
40
41
  args.to,
41
42
  node.silenceSeconds * 1000,
42
43
  cursorAt(cursor)
43
- );
44
+ ), await ports.segments({ from: args.from, path, to: args.to }));
44
45
  return {
45
46
  path,
46
47
  spans: spans.slice(0, 200),
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One in-flight callback per SQL engine.
3
+ *
4
+ * ClickHouse and Postgres each keep a tail promise. A later job waits for
5
+ * that tail before it runs, so two query tools never execute together.
6
+ *
7
+ * @returns {object} run(engine, job)
8
+ *
9
+ * @example
10
+ * const flight = mcpFlight();
11
+ * await flight.run('clickhouse', () => query('SELECT 1'));
12
+ */
13
+ export default function mcpFlight() {
14
+ const tail = {
15
+ clickhouse: Promise.resolve(),
16
+ postgres: Promise.resolve()
17
+ };
18
+ return Object.freeze({
19
+ /**
20
+ * Runs job after the previous job for this engine settles.
21
+ *
22
+ * @param {string} engine - clickhouse or postgres
23
+ * @param {Function} job - callback
24
+ * @returns {Promise<*>} job result
25
+ */
26
+ run(engine, job) {
27
+ const prior = tail[engine];
28
+ let release;
29
+ const held = new Promise((resolve) => {
30
+ release = resolve;
31
+ });
32
+ tail[engine] = prior.then(() => {
33
+ return held;
34
+ }, () => {
35
+ return held;
36
+ });
37
+ return prior.then(job, job).finally(() => {
38
+ release();
39
+ });
40
+ }
41
+ });
42
+ }
@@ -72,7 +72,7 @@ function settle(outcome) {
72
72
  }
73
73
 
74
74
  /**
75
- * Times one tool call, counts it, and tracks the engine gauge.
75
+ * Times one tool call after the engine slot is held.
76
76
  *
77
77
  * @param {string} name - tool name
78
78
  * @param {object} args - tool arguments
@@ -82,9 +82,9 @@ function settle(outcome) {
82
82
  * @returns {Promise<object>} tool result
83
83
  *
84
84
  * @example
85
- * trackedTool('state', {}, {}, () => ({ content: [] }), ctx);
85
+ * invokeTool('state', {}, {}, () => ({ content: [] }), ctx);
86
86
  */
87
- async function trackedTool(name, args, extra, cb, ctx) {
87
+ async function invokeTool(name, args, extra, cb, ctx) {
88
88
  const engine = engineOf(name);
89
89
  const started = Date.now();
90
90
  ctx.metrics.begin(engine);
@@ -94,6 +94,32 @@ async function trackedTool(name, args, extra, cb, ctx) {
94
94
  return settle(outcome);
95
95
  }
96
96
 
97
+ /**
98
+ * Times one tool call, counts it, and tracks the engine gauge.
99
+ *
100
+ * SQL engines wait for the previous query on that engine so the in-flight
101
+ * gauge stays at one. Named plant tools are not queued.
102
+ *
103
+ * @param {string} name - tool name
104
+ * @param {object} args - tool arguments
105
+ * @param {object} extra - SDK extra
106
+ * @param {Function} cb - tool callback
107
+ * @param {object} ctx - journal context
108
+ * @returns {Promise<object>} tool result
109
+ *
110
+ * @example
111
+ * trackedTool('state', {}, {}, () => ({ content: [] }), ctx);
112
+ */
113
+ function trackedTool(name, args, extra, cb, ctx) {
114
+ const engine = engineOf(name);
115
+ if (engine === 'plant') {
116
+ return invokeTool(name, args, extra, cb, ctx);
117
+ }
118
+ return ctx.flight.run(engine, () => {
119
+ return invokeTool(name, args, extra, cb, ctx);
120
+ });
121
+ }
122
+
97
123
  /**
98
124
  * Times one resource read.
99
125
  *
@@ -1,3 +1,5 @@
1
+ import { clickhouseSchema, postgresSchema } from './mcpSchema.js';
2
+
1
3
  /**
2
4
  * Parent path of a node, or an empty string at the root.
3
5
  *
@@ -78,29 +80,69 @@ function body(uri, payload) {
78
80
  }
79
81
 
80
82
  /**
81
- * Registers plant topology and source bindings.
83
+ * Enumerates catalog entries without knowing their vocabulary.
82
84
  *
83
- * Topology has paths, kinds, titles, and liveness. Bindings map a path to a
84
- * topic and a unit. Gateway topics are not part of topology.
85
+ * @param {object} [catalog] - tagCatalog with entries()
86
+ * @returns {Array<object>} entries
87
+ *
88
+ * @example
89
+ * tags({ entries() { return []; } });
90
+ */
91
+ function tags(catalog) {
92
+ if (catalog && typeof catalog.entries === 'function') {
93
+ return catalog.entries();
94
+ }
95
+ return [];
96
+ }
97
+
98
+ /**
99
+ * Registers one JSON resource.
85
100
  *
86
101
  * @param {object} server - McpServer
87
- * @param {object} tree - namespaceTree
102
+ * @param {string} name - resource name
103
+ * @param {string} uri - resource URI
104
+ * @param {string} description - short list description
105
+ * @param {Function} payload - body factory
88
106
  * @returns {void}
89
107
  *
90
108
  * @example
91
- * registerResources(server, tree);
109
+ * publish(server, 'topology', 'plant://topology', 'Paths', () => []);
92
110
  */
93
- export default function registerResources(server, tree) {
94
- server.registerResource('topology', 'plant://topology', {
95
- description: 'Paths, kinds, titles, and liveness',
111
+ function publish(server, name, uri, description, payload) {
112
+ server.registerResource(name, uri, {
113
+ description,
96
114
  mimeType: 'application/json'
97
115
  }, () => {
98
- return body('plant://topology', topology(tree));
116
+ return body(uri, payload());
99
117
  });
100
- server.registerResource('bindings', 'plant://bindings', {
101
- description: 'Source topic and unit for each bound path',
102
- mimeType: 'application/json'
103
- }, () => {
104
- return body('plant://bindings', bindings(tree));
118
+ }
119
+
120
+ /**
121
+ * Registers plant topology, bindings, tags, and storage schemas.
122
+ *
123
+ * Topology has paths, kinds, titles, and liveness. Bindings map a path to a
124
+ * topic and a unit. Tags enumerate the catalog the plant package supplied.
125
+ * Schema resources list scada migration tables. Gateway topics stay out of
126
+ * topology. Operators omit card_uid. kind is opaque.
127
+ *
128
+ * @param {object} server - McpServer
129
+ * @param {object} tree - namespaceTree
130
+ * @param {object} [catalog] - tagCatalog with entries()
131
+ * @returns {void}
132
+ *
133
+ * @example
134
+ * registerResources(server, tree, catalog);
135
+ */
136
+ export default function registerResources(server, tree, catalog) {
137
+ publish(server, 'topology', 'plant://topology', 'Paths, kinds, titles, and liveness', () => {
138
+ return topology(tree);
139
+ });
140
+ publish(server, 'bindings', 'plant://bindings', 'Source topic and unit for each bound path', () => {
141
+ return bindings(tree);
142
+ });
143
+ publish(server, 'tags', 'plant://tags', 'Tag catalog entries from the plant package', () => {
144
+ return tags(catalog);
105
145
  });
146
+ publish(server, 'schema-postgres', 'plant://schema/postgres', 'Postgres tables from scada migrations', postgresSchema);
147
+ publish(server, 'schema-clickhouse', 'plant://schema/clickhouse', 'ClickHouse scada.metrics', clickhouseSchema);
106
148
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Column list for one schema table.
3
+ *
4
+ * @param {string} name - table name
5
+ * @param {Array<object>} columns - name and type
6
+ * @param {Array<string>} [notes] - short notes
7
+ * @returns {object} table
8
+ *
9
+ * @example
10
+ * table('alerts', [{ name: 'id', type: 'integer' }]);
11
+ */
12
+ function table(name, columns, notes) {
13
+ if (!notes) {
14
+ return { columns, name };
15
+ }
16
+ return { columns, name, notes };
17
+ }
18
+
19
+ /**
20
+ * Postgres tables from scada migrations, without operator credentials.
21
+ *
22
+ * kind is an opaque string. Plant brigade tables are not listed.
23
+ *
24
+ * @returns {object} schema body
25
+ *
26
+ * @example
27
+ * postgresSchema().tables[0].name;
28
+ */
29
+ export function postgresSchema() {
30
+ return {
31
+ tables: [
32
+ table('segments', [
33
+ { name: 'machine', type: 'text' },
34
+ { name: 'name', type: 'text' },
35
+ { name: 'start_time', type: 'timestamptz' },
36
+ { name: 'end_time', type: 'timestamptz' },
37
+ { name: 'duration', type: 'float' },
38
+ { name: 'options', type: 'text' },
39
+ { name: 'tags', type: 'text' },
40
+ { name: 'properties', type: 'text' },
41
+ { name: 'resolved', type: 'boolean' },
42
+ { name: 'consumed', type: 'boolean' },
43
+ { name: 'kind', type: 'text' },
44
+ { name: 'updated_at', type: 'timestamptz' }
45
+ ], ['kind is an opaque string']),
46
+ table('operations', [
47
+ { name: 'machine', type: 'text' },
48
+ { name: 'occurred_at', type: 'timestamptz' },
49
+ { name: 'kind', type: 'text' },
50
+ { name: 'key', type: 'text' },
51
+ { name: 'payload', type: 'jsonb' }
52
+ ], ['kind is an opaque string']),
53
+ table('alerts', [
54
+ { name: 'id', type: 'integer' },
55
+ { name: 'name', type: 'text' },
56
+ { name: 'message', type: 'text' },
57
+ { name: 'machine', type: 'text' },
58
+ { name: 'severity', type: 'text' },
59
+ { name: 'timestamp', type: 'timestamptz' },
60
+ { name: 'acknowledged', type: 'boolean' }
61
+ ]),
62
+ table('user_decisions', [
63
+ { name: 'machine', type: 'text' },
64
+ { name: 'start_time', type: 'timestamptz' },
65
+ { name: 'username', type: 'text' },
66
+ { name: 'payload', type: 'text' },
67
+ { name: 'operator_id', type: 'integer' },
68
+ { name: 'decided_at', type: 'timestamptz' }
69
+ ]),
70
+ table('operators', [
71
+ { name: 'id', type: 'integer' },
72
+ { name: 'first_name', type: 'text' },
73
+ { name: 'last_name', type: 'text' },
74
+ { name: 'display_name', type: 'text' }
75
+ ])
76
+ ]
77
+ };
78
+ }
79
+
80
+ /**
81
+ * ClickHouse metrics table. topic is the only series key.
82
+ *
83
+ * @returns {object} schema body
84
+ *
85
+ * @example
86
+ * clickhouseSchema().tables[0].name;
87
+ */
88
+ export function clickhouseSchema() {
89
+ return {
90
+ tables: [
91
+ table('scada.metrics', [
92
+ { name: 'topic', type: 'String' },
93
+ { name: 'ts', type: 'DateTime64(3)' },
94
+ { name: 'value', type: 'Float64' }
95
+ ], ['topic is the only series key'])
96
+ ]
97
+ };
98
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Reads epoch milliseconds from a number, Date, or parseable string.
3
+ *
4
+ * @param {Date|number|string} value - instant
5
+ * @returns {number} epoch milliseconds, or NaN
6
+ *
7
+ * @example
8
+ * epoch('2024-11-02T03:04:05.137Z');
9
+ */
10
+ function epoch(value) {
11
+ if (typeof value === 'number') {
12
+ return value;
13
+ }
14
+ if (value instanceof Date) {
15
+ return value.getTime();
16
+ }
17
+ return Date.parse(String(value));
18
+ }
19
+
20
+ /**
21
+ * Converts an instant and rejects values that are not a time.
22
+ *
23
+ * @param {Date|number|string} value - instant
24
+ * @returns {number} epoch milliseconds
25
+ *
26
+ * @example
27
+ * stamp('2024-11-02T03:04:05.137Z');
28
+ */
29
+ function stamp(value) {
30
+ const at = epoch(value);
31
+ if (Number.isNaN(at)) {
32
+ throw new Error(`Coverage overlay instant is not a time: ${value}`);
33
+ }
34
+ return at;
35
+ }
36
+
37
+ /**
38
+ * Reads a segment bound from one of the accepted field names.
39
+ *
40
+ * @param {object} row - segment
41
+ * @param {Array<string>} names - field names
42
+ * @returns {Date|number|string} instant
43
+ *
44
+ * @example
45
+ * field({ start_time: '2024-11-02T03:04:05.137Z' }, ['start_time', 'from']);
46
+ */
47
+ function field(row, names) {
48
+ const name = names.find((key) => {
49
+ return Object.hasOwn(row, key);
50
+ });
51
+ if (!name) {
52
+ throw new Error(`Coverage overlay bound is missing: ${names.join(',')}`);
53
+ }
54
+ return row[name];
55
+ }
56
+
57
+ /**
58
+ * Normalizes a timeline row to epoch bounds and a name.
59
+ *
60
+ * @param {object} row - segment
61
+ * @returns {{from: number, name: string, to: number}} interval
62
+ *
63
+ * @example
64
+ * bounds({ name: 'off', start_time: 0, end_time: 1 });
65
+ */
66
+ function bounds(row) {
67
+ return {
68
+ from: stamp(field(row, ['start_time', 'from', 'start'])),
69
+ name: typeof row.name === 'string' ? row.name : '',
70
+ to: stamp(field(row, ['end_time', 'to', 'end']))
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Unique sorted cut points of a span and the intervals that cross it.
76
+ *
77
+ * @param {object} span - coverage span
78
+ * @param {Array<object>} rows - normalized intervals
79
+ * @returns {Array<number>} epochs
80
+ *
81
+ * @example
82
+ * cuts({ from: 'a', to: 'b' }, []);
83
+ */
84
+ function cuts(span, rows) {
85
+ const start = stamp(span.from);
86
+ const stop = stamp(span.to);
87
+ const marks = [start, stop];
88
+ rows.forEach((row) => {
89
+ if (row.from > start && row.from < stop) {
90
+ marks.push(row.from);
91
+ }
92
+ if (row.to > start && row.to < stop) {
93
+ marks.push(row.to);
94
+ }
95
+ });
96
+ return Array.from(new Set(marks)).sort((left, right) => {
97
+ return left - right;
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Timeline name that overlaps an open interval, or an empty string.
103
+ *
104
+ * @param {Array<object>} rows - normalized intervals
105
+ * @param {number} from - start epoch
106
+ * @param {number} to - end epoch
107
+ * @returns {string} name
108
+ *
109
+ * @example
110
+ * title([{ from: 0, name: 'off', to: 10 }], 0, 10);
111
+ */
112
+ function title(rows, from, to) {
113
+ const hit = rows.find((row) => {
114
+ return row.from < to && row.to > from;
115
+ });
116
+ if (!hit) {
117
+ return '';
118
+ }
119
+ return hit.name;
120
+ }
121
+
122
+ /**
123
+ * Splits one coverage span on overlapping timeline names.
124
+ *
125
+ * @param {object} span - coverage span
126
+ * @param {Array<object>} rows - normalized intervals
127
+ * @returns {Array<object>} painted spans
128
+ *
129
+ * @example
130
+ * paint({ from: 'a', status: 'present', to: 'b' }, []);
131
+ */
132
+ function paint(span, rows) {
133
+ const marks = cuts(span, rows);
134
+ return marks.slice(0, -1).map((from, index) => {
135
+ const to = marks[index + 1];
136
+ return {
137
+ from: new Date(from).toISOString(),
138
+ phase: title(rows, from, to),
139
+ status: span.status,
140
+ to: new Date(to).toISOString()
141
+ };
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Paints overlapping timeline names onto coverage spans.
147
+ *
148
+ * coverageSpans stays a status classifier. This join splits a span wherever
149
+ * a timeline interval starts or ends and copies that interval name as phase.
150
+ *
151
+ * @param {Array<object>} spans - coverage spans
152
+ * @param {Array<object>} segments - timeline rows
153
+ * @returns {Array<object>} spans with phase
154
+ *
155
+ * @example
156
+ * coverageOverlay([{ from, status: 'present', to }], [{ name, start_time, end_time }]);
157
+ */
158
+ export default function coverageOverlay(spans, segments) {
159
+ const rows = (Array.isArray(segments) ? segments : []).map(bounds);
160
+ const list = Array.isArray(spans) ? spans : [];
161
+ return list.flatMap((span) => {
162
+ return paint(span, rows);
163
+ });
164
+ }