vis-chronicle 0.0.2 → 0.0.3

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/src/render.js ADDED
@@ -0,0 +1,552 @@
1
+
2
+ const moment = require('moment')
3
+ const assert = require('node:assert/strict');
4
+ const globalData = require("./global-data.json")
5
+
6
+ function postprocessDuration(duration)
7
+ {
8
+ if (duration.min)
9
+ duration.min = moment.duration(duration.min)
10
+ if (duration.max)
11
+ duration.max = moment.duration(duration.max)
12
+ if (duration.avg)
13
+ duration.avg = moment.duration(duration.avg)
14
+ return duration
15
+ }
16
+ function postprocessGlobalData()
17
+ {
18
+ for (const expectation of globalData.expectations)
19
+ {
20
+ if (expectation.duration)
21
+ {
22
+ postprocessDuration(expectation.duration)
23
+ }
24
+ }
25
+ }
26
+ postprocessGlobalData()
27
+
28
+ function momentSafeMin(a, b)
29
+ {
30
+ return a ? (b ? moment.min(a, b) : a) : b
31
+ }
32
+
33
+ function momentSafeMax(a, b)
34
+ {
35
+ return a ? (b ? moment.max(a, b) : a) : b
36
+ }
37
+
38
+ const renderer = {}
39
+
40
+ renderer.getExpectation = function(item)
41
+ {
42
+ if (item.expectedDuration)
43
+ {
44
+ return { "duration": item.expectedDuration };
45
+ }
46
+
47
+ for (const expectation of globalData.expectations)
48
+ {
49
+ if (expectation.startQuery && item.startQuery != expectation.startQuery)
50
+ {
51
+ continue;
52
+ }
53
+ if (expectation.endQuery && item.endQuery != expectation.endQuery)
54
+ {
55
+ continue;
56
+ }
57
+ if (expectation.startEndQuery && item.startEndQuery != expectation.startEndQuery)
58
+ {
59
+ continue;
60
+ }
61
+ return expectation;
62
+ }
63
+ assert(false) // expect at least a universal fallback expectation
64
+ return undefined
65
+ }
66
+
67
+ // produces JSON output from the queried data
68
+ renderer.produceOutput = function(inputSpec, items)
69
+ {
70
+ console.log("Producing output...")
71
+
72
+ if (inputSpec.chronicle.shareSuccessiveUncertainty)
73
+ {
74
+ // fill out missing data
75
+ for (const item of items)
76
+ {
77
+ if (item.start_min && !item.start_max && item.end_max) item.start_max = item.end_max.clone()
78
+ if (item.end_max && !item.end_min && item.start_min) item.end_min = item.start_min.clone()
79
+ }
80
+
81
+ // group items with prev/next data into prev/next chains
82
+ //TODO: also use 'series ordinal' property for hinting
83
+ const successionChains = []
84
+ for (const item of items)
85
+ {
86
+ // try to append to an existing chain
87
+ //TODO: does not support branching chains
88
+ var nextForChain = null
89
+ var prevForChain = null
90
+ for (const chain of successionChains)
91
+ {
92
+ if ((item.next && item.next == chain[0].entity)
93
+ && (chain[0].previous && item.entity == chain[0].previous))
94
+ {
95
+ prevForChain = chain
96
+ }
97
+ if ((item.previous && item.previous == chain.at(-1).entity)
98
+ && (chain.at(-1).next && item.entity == chain.at(-1).next))
99
+ {
100
+ nextForChain = chain
101
+ }
102
+ }
103
+
104
+ if (nextForChain && prevForChain)
105
+ {
106
+ // merge chains
107
+ nextForChain.push(item)
108
+ if (nextForChain != prevForChain) //wtf
109
+ {
110
+ for (const prevItem of prevForChain) nextForChain.push(prevItem)
111
+ const prevForChainIdx = successionChains.indexOf(prevForChain)
112
+ successionChains.splice(prevForChainIdx, 1)
113
+ }
114
+ }
115
+ else if (nextForChain)
116
+ {
117
+ nextForChain.push(item)
118
+ }
119
+ else if (prevForChain)
120
+ {
121
+ prevForChain.unshift(item)
122
+ }
123
+ else
124
+ {
125
+ successionChains.push([ item ])
126
+ }
127
+
128
+ // DEBUG: validate
129
+ /*for (const chain of successionChains)
130
+ {
131
+ for (var i2 = 0; i2 < chain.length - 1; i2++)
132
+ {
133
+ assert(chain[i2].entity == chain[i2+1].previous)
134
+ }
135
+ for (var i2 = 1; i2 < chain.length; i2++)
136
+ {
137
+ assert(chain[i2].entity == chain[i2-1].next)
138
+ }
139
+ }*/
140
+ }
141
+
142
+ // split overlapped uncertain regions between adjacent items
143
+ //TODO: create a shared area that visually makes it more clear that the line can slide around?
144
+ for (const chain of successionChains)
145
+ {
146
+ for (var chainIndex = 0; chainIndex < chain.length - 1; chainIndex++)
147
+ {
148
+ var nextIndex = chainIndex + 1
149
+ var curr = chain[chainIndex]
150
+ var next = chain[nextIndex]
151
+ if (!curr.end_min || !next.start_min) continue
152
+
153
+ var overlapStart = moment.max(curr.end_min, next.start_min)
154
+ var overlapEnd = moment.min(curr.end_max, next.start_max)
155
+ if (overlapStart < overlapEnd)
156
+ {
157
+ // include any other items that are uncertain in the entire overlapped region
158
+ while (nextIndex < chain.length - 1)
159
+ {
160
+ // cannot proceed past items with certain regions
161
+ if (chain[nextIndex].start_max < chain[nextIndex].end_min) break
162
+
163
+ if (chain[nextIndex + 1].start_min <= overlapStart && chain[nextIndex + 1].start_max >= overlapEnd)
164
+ {
165
+ nextIndex++
166
+ }
167
+ else break
168
+ }
169
+
170
+ // divide the overlapped region between the involved items
171
+ const itemCount = nextIndex - chainIndex + 1
172
+ const msStart = overlapStart.valueOf()
173
+ const msShare = (overlapEnd.valueOf() - msStart) / itemCount
174
+ chain[chainIndex].end_max = moment(msStart + msShare)
175
+ for (var j = 1; j < nextIndex - chainIndex; j++)
176
+ {
177
+ chain[chainIndex + j].start_min = moment(msStart + msShare * j).add(1, 'second')
178
+ chain[chainIndex + j].end_max = moment(msStart + msShare * (j+1))
179
+
180
+ // region was previously checked to be fully-uncertain
181
+ chain[chainIndex + j].start_max = chain[chainIndex + j].end_max.clone()
182
+ chain[chainIndex + j].end_min = chain[chainIndex + j].start_min.clone()
183
+ }
184
+ chain[nextIndex].start_min = moment(overlapEnd.valueOf() - msShare).add(1, 'second')
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // create timeline items
191
+ // a single input item might be built of multiple timeline segments
192
+ var outputObject = { items: [], groups: inputSpec.groups, options: inputSpec.options }
193
+ for (const item of items)
194
+ {
195
+ var outputItem = {
196
+ id: item.id,
197
+ content: item.label,
198
+ className: item.className,
199
+ comment: item.comment,
200
+ type: item.type,
201
+ wikidata: item.entity
202
+ }
203
+ if (item.group)
204
+ {
205
+ outputItem.group = item.group
206
+ outputItem.subgroup = item.subgroup ? item.subgroup : item.entity
207
+ }
208
+
209
+ const isRangeType = !outputItem.type || outputItem.type == "range" || outputItem.type == "background"
210
+
211
+ // for debugging
212
+ outputItem.className = [ outputItem.className, item.entity ].join(' ')
213
+
214
+ // look up duration expectations
215
+ const expectation = this.getExpectation(item)
216
+ expectation.duration.avg = expectation.duration.avg ?? expectation.duration.max
217
+ assert(expectation && expectation.duration) // expect at least a universal fallback expectation
218
+
219
+ if (!item.start_min && !item.start_max && !item.end_min && !item.end_max)
220
+ {
221
+ //console.warn(`Item ${item.id} has no date data at all.`)
222
+ continue
223
+ }
224
+ assert(Boolean(item.start_min) == Boolean(item.start_max))
225
+ assert(Boolean(item.end_min) == Boolean(item.end_max))
226
+ assert(!(item.start_min > item.start_max))
227
+ assert(!(item.end_min > item.end_max))
228
+
229
+ // restrict uncertainty based on expectations
230
+ //TODO:
231
+
232
+ // Can bg subcomponents be overlaid on the item?
233
+ // If false, they will actually be deducted from the main item.
234
+ var permitBgOverlay = item.type != "background"
235
+
236
+ // Were any bg overlays actually added to this item?
237
+ var usesBgOverlays = false
238
+
239
+ // exclude items that violate itemRange constraints
240
+ //OPT: do this at an earlier stage? (e.g. when running the first query)
241
+ if (item.itemRange)
242
+ {
243
+ if (item.itemRange.min && moment(item.itemRange.min).isAfter(item.end_max))
244
+ continue
245
+ if (item.itemRange.max && moment(item.itemRange.max).isBefore(item.start_min))
246
+ continue
247
+ }
248
+
249
+ if (item.start_max && item.start_max.clone().add(1, 'second') >= item.end_min)
250
+ {
251
+ // no certainty at all: create a single uncertain range
252
+ outputItem.className = [ outputItem.className, 'visc-uncertain' ].join(' ')
253
+ outputItem.start = item.start_min
254
+ outputItem.end = item.end_max
255
+ outputObject.items.push(outputItem)
256
+ continue
257
+ }
258
+
259
+ if (!isRangeType)
260
+ {
261
+ // point type
262
+ //TODO: support ranged boxes etc?
263
+ outputItem.start = moment((item.start_min.valueOf() + item.start_max.valueOf()) / 2)
264
+ if (item.end_min && item.end_max)
265
+ outputItem.end = moment((item.end_min.valueOf() + item.end_max.valueOf()) / 2)
266
+ outputObject.items.push(outputItem)
267
+ continue
268
+ }
269
+
270
+ // handle end date
271
+ if (item.end_min && item.end_max)
272
+ {
273
+ if (item.end_min < item.end_max)
274
+ {
275
+ // uncertain end
276
+
277
+ // find lower bound of uncertain region
278
+ const uncertainMin = item.end_min ?? outputItem.start_max
279
+ assert(uncertainMin)
280
+
281
+ // add uncertain range
282
+ const uncertainElement = {
283
+ id: outputItem.id + "-unc-end",
284
+ className: [outputItem.className, "visc-uncertain", "visc-left-connection"].join(' '),
285
+ type: outputItem.type,
286
+ content: item.label ? "&nbsp;" : "",
287
+ start: uncertainMin,
288
+ end: item.end_max,
289
+ group: item.group,
290
+ subgroup: outputItem.subgroup,
291
+ wikidata: item.entity
292
+ }
293
+ outputObject.items.push(uncertainElement)
294
+
295
+ if (permitBgOverlay)
296
+ {
297
+ uncertainElement.className = [uncertainElement.className, "visc-range-overlay"].join(' ')
298
+ outputItem.end = item.end_max
299
+ usesBgOverlays = true
300
+ }
301
+ else
302
+ {
303
+ // adjust normal range to match
304
+ outputItem.end = uncertainMin
305
+ outputItem.className = [ outputItem.className, 'visc-right-connection' ].join(' ')
306
+ }
307
+ }
308
+ else
309
+ {
310
+ // certain end
311
+ outputItem.end = item.end_max;
312
+ }
313
+ }
314
+ else if (item.end_min && item.start_max < item.end_min)
315
+ {
316
+ // open-ended end with some certainty
317
+ var tailEnd
318
+ const useMax = expectation.duration.max ? expectation.duration.max : moment(expectation.duration.avg.asMilliseconds() * 2)
319
+ if (item.start_max < moment().subtract(useMax))
320
+ {
321
+ // max "possible" is less than 'now'; it is likely this duration is not ongoing but has an unknown end
322
+ //TODO: wikidata special 'no value' should cause the next branch to be taken
323
+ outputItem.end = item.start_max.clone()
324
+ tailEnd = item.start_max.clone().add(expectation.duration.avg)
325
+ }
326
+ else
327
+ {
328
+ // 'now' is within 'max' and so it is a reasonable guess that this duration is ongoing
329
+ const avgDuration = moment.duration(expectation.duration.avg) //HACK: TODO: consistently postprocess expectations, or don't
330
+ const actualDuration = moment.duration(moment().diff(item.start_max)) //TODO: average start here?
331
+ var excessDuration = moment.duration(avgDuration.asMilliseconds()).subtract(actualDuration)
332
+ excessDuration = moment.duration(Math.max(excessDuration.asMilliseconds(), avgDuration.asMilliseconds() * 0.25)) //HACK: magic number
333
+
334
+ outputItem.end = moment()
335
+ tailEnd = outputItem.end.add(excessDuration)
336
+ }
337
+
338
+ // add a "tail" item after the end
339
+ outputObject.items.push({
340
+ id: outputItem.id + "-tail",
341
+ className: [outputItem.className, "visc-right-tail"].join(' '),
342
+ type: outputItem.type,
343
+ content: item.label ? "&nbsp;" : "",
344
+ start: outputItem.end,
345
+ end: tailEnd,
346
+ group: item.group,
347
+ subgroup: outputItem.subgroup,
348
+ wikidata: item.entity
349
+ })
350
+
351
+ outputItem.className = [ outputItem.className, 'visc-right-connection' ].join(' ')
352
+ }
353
+ else
354
+ {
355
+ if (item.start_min && item.start_max && item.start_max > item.start_min)
356
+ {
357
+ // entire range is open-ended, but with an uncertain start region
358
+ outputItem.end = item.start_max.clone()
359
+ tailEnd = item.start_max.clone().add(expectation.duration.avg)
360
+
361
+ // add a "tail" item after the end
362
+ outputObject.items.push({
363
+ id: outputItem.id + "-tail",
364
+ className: [outputItem.className, "visc-right-tail"].join(' '),
365
+ type: outputItem.type,
366
+ content: item.label ? "&nbsp;" : "",
367
+ start: outputItem.end,
368
+ end: tailEnd,
369
+ group: item.group,
370
+ subgroup: outputItem.subgroup,
371
+ wikidata: item.entity
372
+ })
373
+
374
+ outputItem.className = [ outputItem.className, 'visc-right-connection' ].join(' ')
375
+ }
376
+ else
377
+ {
378
+ // entire range is open-ended
379
+ outputItem.start = item.start_min ?? item.start_max
380
+ outputItem.end = outputItem.start.clone().add(expectation.duration.avg)
381
+ outputItem.className = [ outputItem.className, 'visc-open-right' ].join(' ')
382
+ outputObject.items.push(outputItem)
383
+ continue
384
+ }
385
+ }
386
+
387
+ // handle start date
388
+ if (item.start_min && item.start_max)
389
+ {
390
+ if (item.start_max > item.start_min)
391
+ {
392
+ // uncertain start
393
+
394
+ // find upper bound of uncertain region
395
+ var uncertainMax
396
+ if (item.start_max)
397
+ uncertainMax = item.start_max
398
+ else if (outputItem.start)
399
+ uncertainMax = outputItem.start
400
+ else
401
+ uncertainMax = outputItem.end
402
+ assert(uncertainMax)
403
+
404
+ // add uncertain range
405
+ const uncertainElement = {
406
+ id: outputItem.id + "-unc-start",
407
+ className: [outputItem.className, "visc-uncertain", "visc-right-connection"].join(' '),
408
+ type: outputItem.type,
409
+ content: item.label ? "&nbsp;" : "",
410
+ start: item.start_min,
411
+ end: uncertainMax,
412
+ group: item.group,
413
+ subgroup: outputItem.subgroup,
414
+ wikidata: item.entity
415
+ }
416
+ outputObject.items.push(uncertainElement)
417
+
418
+ if (permitBgOverlay)
419
+ {
420
+ uncertainElement.className = [uncertainElement.className, "visc-range-overlay"].join(' ')
421
+ outputItem.start = item.start_min
422
+ usesBgOverlays = true
423
+ }
424
+ else
425
+ {
426
+ // adjust normal range to match
427
+ outputItem.start = uncertainMax
428
+ outputItem.className = [ outputItem.className, 'visc-left-connection' ].join(' ')
429
+ }
430
+ }
431
+ else
432
+ {
433
+ // certain start
434
+ outputItem.start = item.start_min;
435
+ }
436
+ }
437
+ else if (!item.start_min)
438
+ {
439
+ // open-ended start
440
+ outputItem.start = outputItem.end.clone().subtract(expectation.duration.avg)
441
+ outputItem.className = [outputItem.className, "visc-open-left"].join(' ')
442
+ }
443
+
444
+ //TODO: missing death dates inside expected duration: solid to NOW, fade after NOW
445
+ //TODO: accept expected durations and place uncertainly before/after those
446
+
447
+ // if using bg overlays, the label needs to be on its own element so it can sort on top of them
448
+ if (usesBgOverlays && item.label)
449
+ {
450
+ const labelItem = {...outputItem}
451
+ labelItem.id += "-label"
452
+ labelItem.className = [labelItem.className, "visc-toplabel"].join(' '),
453
+ outputObject.items.push(labelItem)
454
+ outputItem.content = outputItem.content ? "&nbsp;" : ""
455
+ }
456
+
457
+ outputObject.items.push(outputItem)
458
+ }
459
+
460
+ // sort the objects into subgroups
461
+ //NOTE: allows same subgroup to be separate across different groups, unlike vis natively
462
+ const stackGroups = {}
463
+ for (const outputItem of outputObject.items)
464
+ {
465
+ if (outputItem.type == "background") continue
466
+
467
+ var stackGroup = stackGroups[outputItem.group]
468
+ if (!stackGroup) stackGroup = stackGroups[outputItem.group] = {}
469
+ var stackSubgroup = stackGroup[outputItem.subgroup]
470
+ if (!stackSubgroup) stackSubgroup = stackGroup[outputItem.subgroup] = { objects: [] }
471
+ stackSubgroup.objects.push(outputItem)
472
+ assert(outputItem.start)
473
+ stackSubgroup.min = momentSafeMin(stackSubgroup.min, outputItem.start)
474
+ if (outputItem.end)
475
+ stackSubgroup.max = momentSafeMax(stackSubgroup.max, outputItem.end)
476
+ }
477
+
478
+ for (const stackGroupKey in stackGroups)
479
+ {
480
+ const stackGroup = stackGroups[stackGroupKey]
481
+
482
+ // sort the subgroups from this group by start time ascending
483
+ const stackSubgroupsArr = []
484
+ for (const stackSubgroup of Object.values(stackGroup)) stackSubgroupsArr.push(stackSubgroup)
485
+ stackSubgroupsArr.sort((a, b) => a.min.valueOf() - b.min.valueOf())
486
+
487
+ // drop subgroups into interlocking rows
488
+ // visjs can do this with stackSubgroups: false, but it's kind of twitchy, so we'll do it statically here instead
489
+ const sublines = []
490
+ for (const stackSubgroup of stackSubgroupsArr)
491
+ {
492
+ var placed = false
493
+ for (var i = 0; i < sublines.length; i++)
494
+ {
495
+ // if the new subgroup overlaps nothing in this line, it can be added
496
+ var overlap = false
497
+ for (const sublineItem of sublines[i])
498
+ {
499
+ if (stackSubgroup.min < sublineItem.max && stackSubgroup.max > sublineItem.min)
500
+ {
501
+ overlap = true
502
+ break
503
+ }
504
+ }
505
+ if (!overlap)
506
+ {
507
+ sublines[i].push(stackSubgroup)
508
+ placed = true
509
+ break
510
+ }
511
+ }
512
+ if (!placed)
513
+ {
514
+ sublines.push([stackSubgroup])
515
+ }
516
+ }
517
+
518
+ // reassign all items to new subgroups based on the line they are in
519
+ for (const sublineIndex in sublines)
520
+ {
521
+ const sublineSubgroup = `${stackGroupKey}#${sublineIndex}`
522
+ for (const stackSubgroup of sublines[sublineIndex])
523
+ {
524
+ for (const object of stackSubgroup.objects)
525
+ {
526
+ object.subgroup = sublineSubgroup
527
+ object.lineNum = parseInt(sublineIndex)
528
+ }
529
+ }
530
+ }
531
+
532
+ // explicitly order the lines
533
+ var groupData = outputObject.groups.find(g => g.id == stackGroupKey)
534
+ if (!groupData) groupData = outputObject.groups[stackGroupKey] = { id: stackGroupKey }
535
+ if (!groupData.subgroupOrder) groupData.subgroupOrder = "lineNum"
536
+ }
537
+
538
+ // finalize all item times from moment to string
539
+ for (const item of outputObject.items)
540
+ {
541
+ assert(item.start)
542
+ item.start = item.start.format("YYYYYY-MM-DDThh:mm:ss")
543
+ if (item.end)
544
+ item.end = item.end.format("YYYYYY-MM-DDThh:mm:ss")
545
+ }
546
+
547
+ delete outputObject.chronicle
548
+
549
+ return JSON.stringify(outputObject, undefined, "\t") //TODO: configure space
550
+ }
551
+
552
+ module.exports = renderer
@@ -3,14 +3,17 @@ const assert = require('node:assert/strict')
3
3
 
4
4
  module.exports = class SparqlBuilder
5
5
  {
6
+ distinct = false
7
+
6
8
  constructor()
7
9
  {
8
10
  this.outParams = []
11
+ this.groupParams = []
9
12
  this.queryTerms = []
10
13
  }
11
14
 
12
15
  // Adds an output parameter to the query
13
- addOutParam(paramName)
16
+ addOutParam(paramName, params)
14
17
  {
15
18
  //TODO: validate name more
16
19
  assert(paramName)
@@ -18,6 +21,18 @@ module.exports = class SparqlBuilder
18
21
  {
19
22
  this.outParams.push(paramName)
20
23
  }
24
+ if (params && params.groupBy)
25
+ {
26
+ this.addGroupParam(paramName)
27
+ }
28
+ }
29
+
30
+ addGroupParam(paramName)
31
+ {
32
+ if (this.groupParams.indexOf(paramName) < 0)
33
+ {
34
+ this.groupParams.push(paramName)
35
+ }
21
36
  }
22
37
 
23
38
  addQueryTerm(term)
@@ -38,21 +53,21 @@ module.exports = class SparqlBuilder
38
53
  this.queryTerms.push(`OPTIONAL{${term}}`)
39
54
  }
40
55
 
41
- addTimeTerm(term, valueVar, timeVar, precisionVar)
56
+ addTimeTerm(term, valueVar, timeVar, precisionVar, params)
42
57
  {
43
58
  assert(term)
44
59
 
45
- this.addOutParam(timeVar)
46
- this.addOutParam(precisionVar)
60
+ this.addOutParam(timeVar, params)
61
+ this.addOutParam(precisionVar, params)
47
62
  this.addQueryTerm(`${term} ${valueVar} wikibase:timeValue ${timeVar}. ${valueVar} wikibase:timePrecision ${precisionVar}.`)
48
63
  }
49
64
 
50
- addOptionalTimeTerm(term, valueVar, timeVar, precisionVar)
65
+ addOptionalTimeTerm(term, valueVar, timeVar, precisionVar, params)
51
66
  {
52
67
  assert(term)
53
68
 
54
- this.addOutParam(timeVar)
55
- this.addOutParam(precisionVar)
69
+ this.addOutParam(timeVar, params)
70
+ this.addOutParam(precisionVar, params)
56
71
  this.addOptionalQueryTerm(`${term} ${valueVar} wikibase:timeValue ${timeVar}. ${valueVar} wikibase:timePrecision ${precisionVar}.`)
57
72
  }
58
73
 
@@ -68,6 +83,8 @@ module.exports = class SparqlBuilder
68
83
  assert(this.queryTerms.length > 0)
69
84
 
70
85
  //TODO: prevent injection
71
- return `SELECT ${this.outParams.join(" ")} WHERE{${this.queryTerms.join(" ")}}`
86
+ const distinct = this.distinct ? "DISTINCT" : ""
87
+ const groupBy = this.groupParams.length > 0 ? `GROUP BY ${this.groupParams.join(' ')}` : ''
88
+ return `SELECT ${distinct} ${this.outParams.join(" ")} WHERE{${this.queryTerms.join(" ")}}${groupBy}`
72
89
  }
73
90
  }