eeb1ee8fadfceb17eb3a27fc6a05050931568fc8
[WebKit-https.git] / Tools / resultsdbpy / resultsdbpy / view / static / library / js / components / TimelineComponents.js
1 // Copyright (C) 2019 Apple Inc. All rights reserved.
2 //
3 // Redistribution and use in source and binary forms, with or without
4 // modification, are permitted provided that the following conditions
5 // are met:
6 // 1.  Redistributions of source code must retain the above copyright
7 //     notice, this list of conditions and the following disclaimer.
8 // 2.  Redistributions in binary form must reproduce the above copyright
9 //     notice, this list of conditions and the following disclaimer in the
10 //     documentation and/or other materials provided with the distribution.
11 //
12 // THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
13 // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
14 // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
15 // DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
16 // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
17 // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
18 // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
19 // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
20 // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
21 // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
23 import {
24     DOM, REF, FP, EventStream
25 }
26 from '../Ref.js';
27
28 import {isDarkMode, createInsertionObservers} from '../Utils.js';
29 import {ListComponent, ListProvider, ListProviderReceiver} from './BaseComponents.js'
30
31 function pointCircleCollisionDetact(point, circle) {
32     return Math.pow(point.x - circle.x, 2) + Math.pow(point.y - circle.y, 2) <= circle.radius * circle.radius;
33 }
34
35 function pointRectCollisionDetect(point, rect) {
36     const diffX = point.x - rect.topLeftX;
37     const diffY = point.y - rect.topLeftY;
38     return diffX <= rect.width && diffY <= rect.height && diffX >= 0 && diffY >= 0;
39 }
40
41 function pointPolygonCollisionDetect(point, polygon) {
42     let res = false;
43     for (let i = 0, j = 1; i < polygon.length; i++, j = i + 1) {
44         if (j === polygon.length )
45             j = 0;
46         if (pointRightRayLineSegmentCollisionDetect(point, polygon[i], polygon[j]))
47             res = !res;
48     }
49     return res;
50 }
51
52 /*
53 * Detact if point right ray have a collision with a line segment
54 *                *
55 *               /
56 *        *---> /
57 *             /
58 *            *
59 */
60 function pointRightRayLineSegmentCollisionDetect(point, lineStart, lineEnd) {
61     const maxX = Math.max(lineStart.x, lineEnd.x);
62     const minX = Math.min(lineStart.x, lineEnd.x);
63     const maxY = Math.max(lineStart.y, lineEnd.y);
64     const minY = Math.min(lineStart.y, lineEnd.y);
65     if ((point.x <= maxX && point.x >= minX || point.x < minX) &&
66         point.y < maxY && point.y > minY &&
67         lineStart.y !== lineEnd.y) {
68         const tanTopAngle = (lineEnd.x - lineStart.x) / (lineEnd.y - lineStart.y);
69         return point.x < lineEnd.x - tanTopAngle * (lineEnd.y - point.y);
70     }
71     return false;
72 }
73
74 function getMousePosInCanvas(event, canvas) {
75     const rect = canvas.getBoundingClientRect();
76     return {
77         x: event.clientX - rect.left,
78         y: event.clientY - rect.top,
79     }
80 }
81
82 function getDevicePixelRatio() {
83     return window.devicePixelRatio;
84 }
85
86 function setupCanvasWidthWithDpr(canvas, width) {
87     const dpr = getDevicePixelRatio();
88     canvas.style.width = `${width}px`;
89     canvas.width = width * dpr;
90     canvas.logicWidth = width;
91 }
92
93 function setupCanvasHeightWithDpr(canvas, height) {
94     const dpr = getDevicePixelRatio();
95     canvas.style.height = `${height}px`;
96     canvas.height = height * dpr;
97     canvas.logicHeight = height;
98 }
99
100 function setupCanvasContextScale(canvas) {
101     const dpr = getDevicePixelRatio();
102     const context = canvas.getContext('2d');
103     context.scale(dpr, dpr);
104 }
105
106 function XScrollableCanvasProvider(exporter, ...childrenFunctions) {
107     const containerRef = REF.createRef({
108         state: {width: 0},
109         onStateUpdate: (element, stateDiff, state) => {
110             if (stateDiff.width)
111                 element.style.width = `${stateDiff.width}px`;
112         },
113     });
114     const scrollRef = REF.createRef({});
115     const scrollEventStream = scrollRef.fromEvent('scroll');
116     const resizeEventStream = new EventStream();
117     window.addEventListener('resize', () => {
118         presenterRef.setState({resize:true});
119     });
120     const resizeContainerWidth = width => {containerRef.setState({width: width})};
121     const presenterRef = REF.createRef({
122         state: {scrollLeft: 0},
123         onElementMount: (element) => {
124             element.style.width = `${element.parentElement.parentElement.offsetWidth}px`;
125             resizeEventStream.add(element.offsetWidth);
126         },
127         onStateUpdate: (element, stateDiff, state) => {
128             if (stateDiff.resize) {
129                 element.style.width = `${element.parentElement.parentElement.offsetWidth}px`;
130                 resizeEventStream.add(element.offsetWidth);
131             }
132         }
133     });
134     // Provide parent functions/event to children to use
135
136     return `<div class="content" ref="${scrollRef}">
137         <div ref="${containerRef}" style="position: relative">
138             <div ref="${presenterRef}" style="position: -webkit-sticky; position:sticky; top:0; left: 0">${
139                 ListProvider((updateChildrenFunctions) => {
140                     if (exporter) {
141                         exporter((children) => {
142                             updateChildrenFunctions(children);
143                             // this make sure the newly added children receive current state
144                             resizeEventStream.replayLast();
145                             scrollEventStream.replayLast();
146                         });
147                     }
148                 }, [resizeContainerWidth, scrollEventStream, resizeEventStream], ...childrenFunctions)
149             }</div>
150         </div>
151     </div>`;
152 }
153
154 class ColorBatchRender {
155     constructor() {
156         this.colorSeqsMap = {};
157     }
158
159     lazyCreateColorSeqs(color, startAction, finalAction) {
160         if (false === color in this.colorSeqsMap)
161             this.colorSeqsMap[color] = [startAction, finalAction];
162     }
163
164     addSeq(color, seqAction) {
165         this.colorSeqsMap[color].push(seqAction);
166     }
167
168     batchRender(context) {
169         for (let color of Object.keys(this.colorSeqsMap)) {
170             const seqs = this.colorSeqsMap[color];
171             seqs[0](context, color);
172             for(let i = 2; i < seqs.length; i++)
173                 seqs[i](context, color);
174             seqs[1](context, color);
175         }
176     }
177     clear() {
178         this.colorSeqsMap = new Map();
179     }
180 }
181
182 function xScrollStreamRenderFactory(height) {
183     return (redraw, element, stateDiff, state) => {
184         const width = typeof stateDiff.width === 'number' ? stateDiff.width : state.width;
185         if (width <= 0)
186             // Nothing to render
187             return;
188         let startX = 0;
189         let renderWidth = width;
190         requestAnimationFrame(() => {
191             if (element.logicWidth !== width) {
192                 setupCanvasWidthWithDpr(element, width);
193                 setupCanvasContextScale(element);
194             }
195             if (element.logicHeight !== height) {
196                 setupCanvasHeightWithDpr(element, height);
197                 setupCanvasContextScale(element);
198             }
199             element.getContext("2d", {alpha: false}).clearRect(startX, 0, renderWidth, element.logicHeight);
200             redraw(startX, renderWidth, element, stateDiff, state);
201         });
202     }
203 }
204
205 // namespace Timeline
206 const Timeline = {};
207
208 Timeline.CanvasSeriesComponent = (dots, scales, option = {}) => {
209     console.assert(dots.length <= scales.length);
210
211     // Get the css value, this component assume to use with webkit.css
212     const computedStyle = getComputedStyle(document.body);
213     let radius = parseInt(computedStyle.getPropertyValue('--smallSize')) / 2;
214     let dotMargin = parseInt(computedStyle.getPropertyValue('--tinySize')) / 2;
215     let fontFamily = computedStyle.getPropertyValue('font-family');
216     let defaultDotColor = computedStyle.getPropertyValue('--greenLight').trim();
217     let defaultEmptyLineColor = computedStyle.getPropertyValue('--grey').trim();
218     let defaultFontSize = parseInt(computedStyle.getPropertyValue('--tinySize'));
219
220     // Get configuration
221     // Default order is left is biggest
222     const reversed = typeof option.reversed === "boolean" ? option.reversed : false;
223     const getScale = typeof option.getScaleFunc === "function" ? option.getScaleFunc : (a) => a;
224     const comp = typeof option.compareFunc === "function" ? option.compareFunc : (a, b) => a - b;
225     const onDotClick = typeof option.onDotClick === "function" ? option.onDotClick : null;
226     const onDotEnter = typeof option.onDotEnter === "function" ? option.onDotEnter : null;
227     const onDotLeave = typeof option.onDotLeave === "function" ? option.onDotLeave : null;
228     const tagHeight = defaultFontSize;
229     const height = option.height ? option.height : 2 * radius + tagHeight;
230     const colorBatchRender = new ColorBatchRender();
231
232     // Draw dot api can be used in user defined render function
233     const drawDot = (context, x, y, isEmpty, tag = null, useRadius, color, emptylineColor) => {
234         useRadius = useRadius ? useRadius : radius;
235         color = color ? color : defaultDotColor;
236         emptylineColor = emptylineColor ? emptylineColor : defaultEmptyLineColor;
237         if (!isEmpty) {
238             // Draw the dot
239             colorBatchRender.lazyCreateColorSeqs(color, (context) => {
240                 context.beginPath();
241             }, (context, color) => {
242                 context.fillStyle = color;
243                 context.fill();
244             });
245             colorBatchRender.addSeq(color, (context, color) => {
246                 context.arc(x + dotMargin + radius, y, radius, 0, 2 * Math.PI);
247             });
248
249         } else {
250             // Draw the empty
251             colorBatchRender.lazyCreateColorSeqs(emptylineColor, (context) => {
252                 context.beginPath();
253             }, (context, color) => {
254                 context.strokeStyle = color;
255                 context.stroke();
256             });
257             colorBatchRender.addSeq(emptylineColor, (context) => {
258                 context.moveTo(x + dotMargin, y);
259                 context.lineTo(x + dotMargin + 2 * radius, y);
260                 context.lineWidth = 1;
261             });
262         }
263
264         // Draw the tag
265         if (typeof tag === "number" || typeof tag === "string") {
266             context.font = `${fontFamily} ${defaultFontSize}px`;
267             context.fillStyle = color;
268             const tagSize = context.measureText(tag);
269             context.fillText(tag, x + dotMargin + radius - tagSize.width / 2, radius * 2 + tagSize.emHeightAscent);
270         }
271     };
272     const render = typeof option.renderFactory === "function" ? option.renderFactory(drawDot) : (dot, context, x, y) => drawDot(context, x, y, !dot);
273     const sortData = option.sortData === true ? option.sortData : false;
274
275     // Initialize
276     const initDots = dots.map((dot) => dot);
277     const initScales = scales.map((scale) => scale);
278     if (sortData) {
279         initDots.sort((a, b) => comp(getScale(a), getScale(b)));
280         initScales.sort(comp);
281     }
282     const filterDots = (dots) => dots.filter(dot => typeof dot._x === 'number');
283     let inCacheDots = [];
284     const getMouseEventTirggerDots = (e, scrollLeft, element) => {
285         const {x, y} = getMousePosInCanvas(e, element);
286         return inCacheDots.filter(dot => pointCircleCollisionDetact({x, y},
287             {
288                 x: dot._dotCenter.x - (scrollLeft - dot._cachedScrollLeft),
289                 y: dot._dotCenter.y,
290                 radius: radius
291             }));
292     }
293
294     const dotWidth = 2 * (radius + dotMargin);
295     const padding = 100 * dotWidth / getDevicePixelRatio();
296     const xScrollStreamRender = xScrollStreamRenderFactory(height);
297
298     const redraw = (startX, renderWidth, element, stateDiff, state) => {
299         const scrollLeft = typeof stateDiff.scrollLeft === 'number' ? stateDiff.scrollLeft : state.scrollLeft;
300         const scales = stateDiff.scales ? stateDiff.scales : state.scales;
301         const dots = stateDiff.dots ? stateDiff.dots : state.dots;
302         // This color maybe change when switch dark/light mode
303         const defaultLineColor = getComputedStyle(document.body).getPropertyValue('--borderColorInlineElement');
304
305         const context = element.getContext("2d", { alpha: false });
306         // Clear pervious batchRender
307         colorBatchRender.clear();
308         // Draw the time line
309         colorBatchRender.lazyCreateColorSeqs(defaultLineColor, (context) => {
310             context.beginPath();
311         }, (context, color) => {
312             context.lineWidth = 1;
313             context.strokeStyle = color;
314             context.stroke();
315         });
316         colorBatchRender.addSeq(defaultLineColor, (context) => {
317             context.moveTo(startX, radius);
318             context.lineTo(startX + renderWidth, radius);
319         });
320
321         // Draw the dots
322         // First, Calculate the render range:
323         let startScalesIndex = Math.floor((scrollLeft + startX) / dotWidth);
324         if (startScalesIndex < 0)
325             startScalesIndex = 0;
326         let endScalesIndex = startScalesIndex + Math.ceil((renderWidth) / dotWidth);
327         if (endScalesIndex >= scales.length)
328             endScalesIndex = scales.length - 1;
329         let currentDotIndex = startScalesIndex - (scales.length - dots.length);
330         if (currentDotIndex < 0)
331             currentDotIndex = 0;
332         for (let i = currentDotIndex; i <= startScalesIndex; i++) {
333             const compResult = comp(scales[startScalesIndex], getScale(dots[currentDotIndex]));
334             if (!reversed) {
335                 if (compResult > 0)
336                     currentDotIndex ++;
337                 else
338                     break;
339             } else {
340                 if (compResult < 0)
341                     currentDotIndex ++;
342                 else
343                     break;
344             }
345         }
346
347         // Use this to decrease colision search scope
348         inCacheDots = [];
349         for (let i = startScalesIndex; i <= endScalesIndex; i++) {
350             let x = i * dotWidth - scrollLeft;
351             if (currentDotIndex < dots.length && comp(scales[i], getScale(dots[currentDotIndex])) === 0) {
352                 render(dots[currentDotIndex], context, x, radius);
353                 dots[currentDotIndex]._dotCenter = {x: x + dotMargin + radius, y: radius};
354                 dots[currentDotIndex]._cachedScrollLeft = scrollLeft;
355                 inCacheDots.push(dots[currentDotIndex]);
356                 currentDotIndex += 1;
357             } else
358                 render(null, context, x, radius);
359         }
360         colorBatchRender.batchRender(context);
361     };
362
363     return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => {
364         const mouseMove = (e) => {
365             let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, canvasRef.element);
366             if (dots.length) {
367                 if (onDotEnter) {
368                     dots[0].tipPoints = [
369                         {x: dots[0]._dotCenter.x, y: dots[0]._dotCenter.y - 3 * radius / 2},
370                         {x: dots[0]._dotCenter.x, y: dots[0]._dotCenter.y + radius / 2},
371                     ];
372                     onDotEnter(dots[0], e, canvasRef.element.getBoundingClientRect());
373                 }
374                 canvasRef.element.style.cursor = "pointer";
375             } else {
376                 if (onDotLeave)
377                     onDotLeave(e, canvasRef.element.getBoundingClientRect());
378                 canvasRef.element.style.cursor = "default";
379             }
380         }
381         const onScrollAction = (e) => {
382             canvasRef.setState({scrollLeft: e.target.scrollLeft / getDevicePixelRatio()});
383             mouseMove(e);
384         };
385         const onResizeAction = (width) => {
386             canvasRef.setState({width: width});
387         };
388
389         const canvasRef = REF.createRef({
390             state: {
391                 dots: initDots,
392                 scales: initScales,
393                 scrollLeft: 0,
394                 width: 0,
395                 onScreen: false,
396             },
397             onElementMount: (element) => {
398                 setupCanvasHeightWithDpr(element, height);
399                 setupCanvasContextScale(element);
400                 if (onDotClick) {
401                     element.addEventListener('click', (e) => {
402                         let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, element);
403                         if (dots.length)
404                             onDotClick(dots[0], e);
405                     });
406                 }
407
408                 if (onDotClick || onDotEnter || onDotLeave)
409                     element.addEventListener('mousemove', mouseMove);
410                 if (onDotLeave)
411                     element.addEventListener('mouseleave', (e) => onDotLeave(e, element.getBoundingClientRect()));
412
413                 createInsertionObservers(element, (entries) => {
414                     canvasRef.setState({onScreen: entries[0].isIntersecting});
415                 }, 0, 0.01, 0.01);
416             },
417             onElementUnmount: (element) => {
418                 onContainerScroll.stopAction(onScrollAction);
419                 onResize.stopAction(onResizeAction);
420                 // Clean the canvas, free its memory
421                 element.width = 0;
422                 element.height = 0;
423             },
424             onStateUpdate: (element, stateDiff, state) => {
425                 if (!state.onScreen && !stateDiff.onScreen)
426                     return;
427                 if (stateDiff.scales || stateDiff.dots || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number' || stateDiff.onScreen) {
428                     if (stateDiff.scales)
429                         stateDiff.scales = stateDiff.scales.map(x => x);
430                     if (stateDiff.dots)
431                         stateDiff.dots = stateDiff.dots.map(x => x);
432                     xScrollStreamRender(redraw, element, stateDiff, state);
433                 }
434             }
435         });
436
437         updateContainerWidth(scales.length * dotWidth * getDevicePixelRatio());
438         const updateData = (dots, scales) => {
439             updateContainerWidth(scales.length * dotWidth * getDevicePixelRatio());
440             canvasRef.setState({
441                 dots: dots,
442                 scales: scales,
443             });
444         };
445         if (typeof option.exporter === "function")
446             option.exporter(updateData);
447         onContainerScroll.action(onScrollAction);
448         onResize.action(onResizeAction);
449         return `<div class="series">
450             <canvas ref="${canvasRef}" width="0" height="0">
451         </div>`;
452     });
453 }
454
455 Timeline.ExpandableSeriesComponent = (mainSeries, subSerieses, exporter) => {
456     const ref = REF.createRef({
457         state: {expanded: false},
458         onStateUpdate: (element, stateDiff) => {
459             if (stateDiff.expanded === false) {
460                 element.children[0].style.display = 'none';
461                 element.children[1].style.display = 'block';
462                 element.children[2].style.display = 'none';
463             } else if (stateDiff.expanded === true) {
464                 element.children[0].style.display = 'block';
465                 element.children[1].style.display = 'none';
466                 element.children[2].style.display = 'block';
467             }
468         }
469     });
470     if (exporter)
471         exporter((expanded) => ref.setState({expanded: expanded}));
472     return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => {
473         return `<div class="groupSeries" ref="${ref}">
474             <div class="series" style="display:none;"></div>
475             <div>${mainSeries(updateContainerWidth, onContainerScroll, onResize)}</div>
476             <div style="display:none">${subSerieses.map((subSeries) => subSeries(updateContainerWidth, onContainerScroll, onResize)).join("")}</div>
477         </div>`;
478     });
479 }
480
481 Timeline.HeaderComponent = (label) => {
482     return `<div class="series">${label}</div>`;
483 }
484
485 Timeline.ExpandableHeaderComponent = (mainLabel, subLabels, exporter) => {
486     const ref = REF.createRef({
487         state: {expanded: false},
488         onStateUpdate: (element, stateDiff) => {
489             if (stateDiff.expanded === false)
490                 element.children[1].style.display = "none";
491             else if (stateDiff.expanded === true)
492                 element.children[1].style.display = "block";
493         }
494     });
495     if (exporter)
496         exporter((expanded) => ref.setState({expanded: expanded}));
497     return `<div ref="${ref}">
498         <div class="series">
499             ${mainLabel}
500         </div>
501         <div style="display:none">
502             ${subLabels.map(label => `<div class="series">${label}</div>`).join("")}
503         </div>
504     </div>`;
505 }
506
507 Timeline.SeriesWithHeaderComponent = (header, series) => {
508     return {header, series};
509 }
510
511 Timeline.ExpandableSeriesWithHeaderExpanderComponent = (mainSeriesWithLable, ...subSeriesWithLable) => {
512     const ref = REF.createRef({
513         state: {expanded: false},
514         onStateUpdate: (element, stateDiff) => {
515             if (stateDiff.expanded === false)
516                 element.innerText = "+";
517             else if (stateDiff.expanded === true)
518                 element.innerText = "-";
519         }
520     });
521     const mainLabel = mainSeriesWithLable.header;
522     const subLabels = subSeriesWithLable.map(item => item.header);
523     const mainSeries = mainSeriesWithLable.series;
524     const subSerieses = subSeriesWithLable.map(item => item.series);
525     const expandedEvent = new EventStream();
526     const clickEvent = ref.fromEvent('click').action(() => {
527         let expanded = ref.state.expanded;
528         expandedEvent.add(!expanded);
529         ref.setState({expanded: !expanded});
530     });
531     const composer = FP.composer(FP.currying((setHeaderExpand, setSeriesExpand) => {
532         expandedEvent.action((expanded) => {
533             setHeaderExpand(expanded);
534             setSeriesExpand(expanded);
535         })
536     }));
537     return {
538         header: Timeline.ExpandableHeaderComponent(`<a href="javascript:void(0)" ref="${ref}">+</a>` + mainLabel, subLabels, composer),
539         series: Timeline.ExpandableSeriesComponent(mainSeries, subSerieses, composer),
540     }
541 }
542
543 Timeline.CanvasXAxisComponent = (scales, option = {}) => {
544     // Get configuration
545     const getScaleKey = typeof option.getScaleFunc === "function" ? option.getScaleFunc : (a) => a;
546     const comp = typeof option.compareFunc === "function" ? option.compareFunc : (a, b) => a - b;
547     const onScaleClick = typeof option.onScaleClick === "function" ? option.onScaleClick : null;
548     const onScaleEnter = typeof option.onScaleEnter === "function" ? option.onScaleEnter : null;
549     const onScaleLeave = typeof option.onScaleLeave === "function" ? option.onScaleLeave : null;
550     const sortData = option.sortData === true ? option.sortData : false;
551     const getLabel = typeof option.getLabelFunc === "function" ? option.getLabelFunc : (a) => a;
552     const isTop = typeof option.isTop === "boolean" ? option.isTop : false;
553
554     // Get the css value, this component assume to use with webkit.css
555     const computedStyle = getComputedStyle(document.body);
556     const fontFamily = computedStyle.getPropertyValue('font-family');
557     const fontSize = computedStyle.getPropertyValue('--tinySize');
558     const fontSizeNumber = parseInt(fontSize);
559     const fontColor = onScaleClick ? computedStyle.getPropertyValue('--linkColor') : computedStyle.getPropertyValue('color');
560     const fontRotate = 60 * Math.PI / 180;
561     const fontTopRotate = 300 * Math.PI / 180;
562     const linkColor = computedStyle.getPropertyValue('--linkColor');
563     const scaleWidth = parseInt(computedStyle.getPropertyValue('--smallSize')) + parseInt(computedStyle.getPropertyValue('--tinySize'));
564     const scaleTagLineHeight = parseInt(computedStyle.getPropertyValue('--smallSize'));
565     const scaleTagLinePadding = 10;
566     const scaleBroadLineHeight = parseInt(computedStyle.getPropertyValue('--tinySize')) / 2;
567     const maxinumTextHeight = scaleWidth * 4.5;
568     const canvasHeight = typeof option.height === "number" ? option.height : parseInt(computedStyle.getPropertyValue('--smallSize')) * 5;
569     const sqrt3 = Math.sqrt(3);
570     const colorBatchRender = new ColorBatchRender();
571
572     const drawScale = (scaleLabel, group, context, x, y, isHoverable, lineColor, groupColor) => {
573         const computedStyle = getComputedStyle(document.body);
574         const usedLineColor = lineColor ? lineColor : computedStyle.getPropertyValue('--borderColorInlineElement');
575         const usedGroupColor = groupColor ? groupColor : isDarkMode() ? computedStyle.getPropertyValue('--white') : computedStyle.getPropertyValue('--black');
576         const totalWidth = group * scaleWidth;
577         const baseLineY = isTop ? y + canvasHeight - scaleBroadLineHeight : y + scaleBroadLineHeight;
578         const middlePointX = x + totalWidth / 2;
579         if (group > 1) {
580             colorBatchRender.lazyCreateColorSeqs(usedGroupColor, (context) => {
581                 context.beginPath();
582             }, (context, color) => {
583                 context.lineWidth = 1;
584                 context.strokeStyle = color;
585                 context.stroke();
586             });
587             colorBatchRender.addSeq(usedGroupColor, (context) => {
588                 context.moveTo(x + context.lineWidth, isTop ? canvasHeight : y);
589                 context.lineTo(x + context.lineWidth, baseLineY);
590                 context.moveTo(x, baseLineY);
591                 context.lineTo(x + totalWidth, baseLineY);
592                 context.moveTo(x + totalWidth, isTop ? canvasHeight : y);
593                 context.lineTo(x + totalWidth, baseLineY);
594                 context.moveTo(middlePointX, baseLineY);
595                 if (!isTop)
596                     context.lineTo(middlePointX, baseLineY + scaleTagLineHeight - scaleTagLinePadding);
597                 else
598                     context.lineTo(middlePointX, baseLineY - scaleTagLineHeight + scaleTagLinePadding);
599             });
600         } else {
601             colorBatchRender.lazyCreateColorSeqs(usedLineColor, (context) => {
602                 context.beginPath();
603             }, (context, color) => {
604                 context.lineWidth = 1;
605                 context.strokeStyle = color;
606                 context.stroke();
607             });
608             colorBatchRender.addSeq(usedLineColor, (context) => {
609                 context.moveTo(middlePointX, baseLineY);
610                 if (!isTop)
611                     context.lineTo(middlePointX, baseLineY + scaleTagLineHeight - scaleTagLinePadding);
612                 else
613                     context.lineTo(middlePointX, baseLineY - scaleTagLineHeight + scaleTagLinePadding);
614             });
615         }
616         // Draw Tag
617         context.font = `${fontSize} ${fontFamily}`;
618         context.fillStyle = fontColor;
619         context.save();
620         if (!isTop) {
621             context.translate(middlePointX, baseLineY + scaleTagLineHeight);
622             context.rotate(fontRotate);
623             context.translate(0 - middlePointX, 0 - baseLineY - scaleTagLineHeight);
624             context.fillText(getLabel(scaleLabel), middlePointX, baseLineY + scaleTagLineHeight);
625         } else {
626             context.translate(middlePointX, baseLineY - scaleTagLineHeight);
627             context.rotate(fontTopRotate);
628             context.translate(0 - middlePointX, 0 - baseLineY + scaleTagLineHeight);
629             context.fillText(getLabel(scaleLabel), middlePointX, baseLineY - scaleTagLineHeight);
630         }
631         context.restore();
632     };
633     const render = typeof option.renderFactory === "function" ? option.renderFactory(drawScale) : (scaleLabel, scaleGroup, context, x, y) => drawScale(scaleLabel, scaleGroup, context, x, y);
634
635     const padding = 100 * scaleWidth / getDevicePixelRatio();
636     const xScrollStreamRender = xScrollStreamRenderFactory(canvasHeight);
637     let onScreenScales = [];
638
639     const getMouseEventTirggerScales = (e, scrollLeft, element) => {
640         const {x, y} = getMousePosInCanvas(e, element);
641         return onScreenScales.filter(scale => {
642             const labelLength = getLabel(scale.label).length;
643             const width = labelLength * fontSizeNumber / 2;
644             const height = labelLength * fontSizeNumber / 2 * sqrt3;
645             const point1 = {
646                 x: scale._tagTop.x - scrollLeft - (isTop ? fontSizeNumber / 2 * sqrt3 : 0),
647                 y: scale._tagTop.y + (fontSizeNumber / 2 + scaleTagLineHeight) * (isTop ? -1 : 1),
648             };
649             const point2 = {
650                 x: point1.x + fontSizeNumber / 2 * sqrt3,
651                 y: scale._tagTop.y + scaleTagLineHeight  * (isTop ? -1 : 1)
652             };
653             const point3 = {
654                 x: point2.x + width,
655                 y: point2.y + height * (isTop ? -1 : 1),
656             };
657             const point4 = {
658                 x: point1.x + width,
659                 y: point1.y + height * (isTop ? -1 : 1),
660             };
661             return pointPolygonCollisionDetect({x, y}, [point1, point2, point3, point4]);
662         });
663     };
664     const redraw = (startX, renderWidth, element, stateDiff, state) => {
665         const scrollLeft = typeof stateDiff.scrollLeft === 'number' ? stateDiff.scrollLeft : state.scrollLeft;
666         const scales = stateDiff.scales ? stateDiff.scales : state.scales;
667         const scalesMapLinkList = stateDiff.scalesMapLinkList ? stateDiff.scalesMapLinkList : state.scalesMapLinkList;
668         const width = typeof stateDiff.width === 'number' ? stateDiff.width : state.width;
669         const usedLineColor = computedStyle.getPropertyValue('--borderColorInlineElement');
670         const baseLineY = isTop ? canvasHeight - scaleBroadLineHeight : scaleBroadLineHeight;
671         const context = element.getContext("2d", { alpha: false });
672         let currentStartScaleIndex = Math.floor(scrollLeft / scaleWidth);
673         if (currentStartScaleIndex < 0)
674             currentStartScaleIndex = 0;
675         const currentStartScaleKey = getScaleKey(scales[currentStartScaleIndex]);
676         let currentEndScaleIndex = Math.ceil((scrollLeft + renderWidth) / scaleWidth);
677         currentEndScaleIndex = currentEndScaleIndex >= scales.length ? scales.length - 1 : currentEndScaleIndex;
678         const currentEndScaleKey = getScaleKey(scales[currentEndScaleIndex]);
679         const currentStartNode = scalesMapLinkList.map.get(currentStartScaleKey);
680         const currentEndNode = scalesMapLinkList.map.get(currentEndScaleKey);
681         if (!currentEndNode) {
682             console.error(currentEndScaleKey);
683         }
684         let now = currentStartNode;
685         // Clear pervious batch render
686         colorBatchRender.clear();
687         colorBatchRender.lazyCreateColorSeqs(usedLineColor, (context) => {
688             context.beginPath();
689         }, (context, color) => {
690             context.lineWidth = 1;
691             context.strokeStyle = color;
692             context.stroke();
693         });
694         colorBatchRender.addSeq(usedLineColor, (context) => {
695             context.moveTo(0, baseLineY);
696             context.lineTo(element.logicWidth, baseLineY);
697         });
698
699         onScreenScales = [];
700         while (now != currentEndNode.next) {
701             const label = now.label;
702             const group = now.group;
703             render(label, group, context, now.x - scrollLeft, 0);
704             now._tagTop = {x: now.x + group * scaleWidth / 2, y: isTop ? canvasHeight - scaleBroadLineHeight : scaleBroadLineHeight};
705             onScreenScales.push(now);
706             now = now.next;
707         }
708         colorBatchRender.batchRender(context);
709     };
710
711     // Initialize
712     // Do a copy, sorting will not have side effect
713     const initScales = scales.map((item) => item);
714     if (sortData)
715         initScales.sort(comp);
716
717     const getScalesMapLinkList = (scales) => {
718         const res = {
719             map: new Map(),
720             linkListHead: {next: null, group: null}
721         };
722         let now = res.linkListHead;
723         let currentX = 0;
724         scales.forEach((scale) => {
725             let key = getScaleKey(scale);
726             if (res.map.has(key))
727                 res.map.get(key).group += 1;
728             else {
729                 now.next = {next: null, group: 1, label: scale, x: currentX};
730                 now = now.next;
731                 res.map.set(key, now);
732             }
733             currentX += scaleWidth;
734         });
735         return res;
736     };
737     const initScaleGroupMapLinkList = getScalesMapLinkList(initScales);
738
739
740     return {
741         series: ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => {
742             const mouseMove = (e) => {
743                 let scales = getMouseEventTirggerScales(e, canvasRef.state.scrollLeft, canvasRef.element);
744                 if (scales.length) {
745                     if (onScaleEnter) {
746                         const labelLength = getLabel(scales[0].label).length;
747                         scales[0].tipPoints = [{
748                             x: scales[0]._tagTop.x - canvasRef.state.scrollLeft,
749                             y: scales[0]._tagTop.y + scaleTagLineHeight * (isTop ? -1 : 0),
750                         }, {
751                             x: scales[0]._tagTop.x - canvasRef.state.scrollLeft + labelLength * fontSizeNumber / 3 - scaleTagLineHeight * (isTop ? 1 : .25),
752                             y: scales[0]._tagTop.y + (labelLength * fontSizeNumber / 2 * sqrt3) * (isTop ? -1 : 1) + scaleTagLineHeight * (isTop ? 1 : 0),
753                         }];
754                         onScaleEnter(scales[0], e, canvasRef.element.getBoundingClientRect());
755                     }
756                     canvasRef.element.style.cursor = "pointer";
757                 } else {
758                     if (onScaleEnter)
759                         onScaleLeave(e, canvasRef.element.getBoundingClientRect());
760                     canvasRef.element.style.cursor = "default";
761                 }
762             }
763             const onScrollAction = (e) => {
764                 canvasRef.setState({scrollLeft: e.target.scrollLeft / getDevicePixelRatio()});
765                 mouseMove(e);
766             };
767             const onResizeAction = (width) => {
768                 canvasRef.setState({width: width});
769             };
770
771             const canvasRef = REF.createRef({
772                 state: {
773                     scrollLeft: 0,
774                     width: 0,
775                     scales: initScales,
776                     scalesMapLinkList: initScaleGroupMapLinkList
777                 },
778                 onElementMount: (element) => {
779                     setupCanvasHeightWithDpr(element, canvasHeight);
780                     setupCanvasContextScale(element);
781                     if (onScaleClick) {
782                         element.addEventListener('click', (e) => {
783                             let scales = getMouseEventTirggerScales(e, canvasRef.state.scrollLeft, element);
784                             if (scales.length)
785                                 onScaleClick(scales[0], e);
786                         });
787                     }
788
789                     if (onScaleClick || onScaleEnter || onScaleLeave)
790                         element.addEventListener('mousemove', mouseMove);
791                     if (onScaleLeave)
792                         element.addEventListener('mouseleave', (e) => onScaleLeave(e, element.getBoundingClientRect()));
793                 },
794                 onElementUnmount: (element) => {
795                     onContainerScroll.stopAction(onScrollAction);
796                     onResize.stopAction(onResizeAction);
797                 },
798                 onStateUpdate: (element, stateDiff, state) => {
799                     if (stateDiff.scales || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number') {
800                         xScrollStreamRender(redraw, element, stateDiff, state);
801                     }
802                 }
803             });
804
805             updateContainerWidth(scales.length * scaleWidth * getDevicePixelRatio());
806             const updateData = (scales) => {
807                 // In case of modification while rendering
808                 const scalesCopy = scales.map(x => x);
809                 updateContainerWidth(scalesCopy.length * scaleWidth * getDevicePixelRatio());
810                 canvasRef.setState({
811                     scales: scalesCopy,
812                     scalesMapLinkList: getScalesMapLinkList(scalesCopy)
813                 });
814             }
815             if (typeof option.exporter === "function")
816                 option.exporter(updateData);
817             onContainerScroll.action(onScrollAction);
818             onResize.action(onResizeAction);
819             return `<div class="x-axis">
820                 <canvas ref="${canvasRef}">
821             </div>`;
822         }),
823         isAxis: true, // Mark self as an axis,
824         height: canvasHeight, // Expose Height to parent
825     };
826 }
827
828 Timeline.CanvasContainer = (exporter, ...children) => {
829     let headerAxisPlaceHolderHeight = 0;
830     let topAxis = true;
831     const upackChildren = (children) => {
832         const headers = [];
833         const serieses = [];
834         children.forEach(child => {
835             if (false === "series" in child) {
836                 console.error("Please use Timeline.SeriesWithHeaderComponent or Timeline.ExpandableSeriesWithHeaderExpanderComponent or Timeline.CanvasXAxisComponent as children");
837                 return;
838             }
839             if (child.header)
840                 headers.push(child.header);
841             serieses.push(child.series);
842             if (child.isAxis && topAxis)
843                 headerAxisPlaceHolderHeight += child.height;
844             else if (topAxis)
845                 topAxis = false;
846         });
847         return {headers, serieses};
848     };
849     const {headers, serieses} = upackChildren(children);
850     let composer = FP.composer(FP.currying((updateHeaders, updateSerieses) => {
851         if (exporter)
852             exporter((newChildren) => {
853                 const {headers, serieses} = upackChildren(newChildren);
854                 updateHeaders(headers);
855                 updateSerieses(serieses);
856             });
857     }));
858     return (
859         `<div class="timeline">
860             <div class="header" style="padding-top:${headerAxisPlaceHolderHeight}px">
861                 ${ListComponent(composer, ...headers)}
862             </div>
863             ${XScrollableCanvasProvider(composer, ...serieses)}
864         </div>`
865     );
866 }
867
868 export {
869     Timeline
870 };