| // Copyright (C) 2019 Apple Inc. All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions |
| // are met: |
| // 1. Redistributions of source code must retain the above copyright |
| // notice, this list of conditions and the following disclaimer. |
| // 2. Redistributions in binary form must reproduce the above copyright |
| // notice, this list of conditions and the following disclaimer in the |
| // documentation and/or other materials provided with the distribution. |
| // |
| // THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND |
| // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
| // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| // DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR |
| // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| import { |
| DOM, REF, FP, EventStream |
| } |
| from '../Ref.js'; |
| |
| import {isDarkMode, createInsertionObservers} from '../Utils.js'; |
| import {ListComponent, ListProvider, ListProviderReceiver} from './BaseComponents.js' |
| |
| function pointCircleCollisionDetact(point, circle) { |
| return Math.pow(point.x - circle.x, 2) + Math.pow(point.y - circle.y, 2) <= circle.radius * circle.radius; |
| } |
| |
| function pointRectCollisionDetect(point, rect) { |
| const diffX = point.x - rect.topLeftX; |
| const diffY = point.y - rect.topLeftY; |
| return diffX <= rect.width && diffY <= rect.height && diffX >= 0 && diffY >= 0; |
| } |
| |
| function pointPolygonCollisionDetect(point, polygon) { |
| let res = false; |
| for (let i = 0, j = 1; i < polygon.length; i++, j = i + 1) { |
| if (j === polygon.length ) |
| j = 0; |
| if (pointRightRayLineSegmentCollisionDetect(point, polygon[i], polygon[j])) |
| res = !res; |
| } |
| return res; |
| } |
| |
| /* |
| * Detact if point right ray have a collision with a line segment |
| * * |
| * / |
| * *---> / |
| * / |
| * * |
| */ |
| function pointRightRayLineSegmentCollisionDetect(point, lineStart, lineEnd) { |
| const maxX = Math.max(lineStart.x, lineEnd.x); |
| const minX = Math.min(lineStart.x, lineEnd.x); |
| const maxY = Math.max(lineStart.y, lineEnd.y); |
| const minY = Math.min(lineStart.y, lineEnd.y); |
| if ((point.x <= maxX && point.x >= minX || point.x < minX) && |
| point.y < maxY && point.y > minY && |
| lineStart.y !== lineEnd.y) { |
| const tanTopAngle = (lineEnd.x - lineStart.x) / (lineEnd.y - lineStart.y); |
| return point.x < lineEnd.x - tanTopAngle * (lineEnd.y - point.y); |
| } |
| return false; |
| } |
| |
| function getMousePosInCanvas(event, canvas) { |
| const rect = canvas.getBoundingClientRect(); |
| return { |
| x: event.clientX - rect.left, |
| y: event.clientY - rect.top, |
| } |
| } |
| |
| function getDevicePixelRatio() { |
| return window.devicePixelRatio; |
| } |
| |
| function setupCanvasWidthWithDpr(canvas, width) { |
| const dpr = getDevicePixelRatio(); |
| canvas.style.width = `${width}px`; |
| canvas.width = width * dpr; |
| canvas.logicWidth = width; |
| } |
| |
| function setupCanvasHeightWithDpr(canvas, height) { |
| const dpr = getDevicePixelRatio(); |
| canvas.style.height = `${height}px`; |
| canvas.height = height * dpr; |
| canvas.logicHeight = height; |
| } |
| |
| function setupCanvasContextScale(canvas) { |
| const dpr = getDevicePixelRatio(); |
| const context = canvas.getContext('2d'); |
| context.scale(dpr, dpr); |
| } |
| |
| function XScrollableCanvasProvider(exporter, ...childrenFunctions) { |
| const containerRef = REF.createRef({ |
| state: {width: 0}, |
| onStateUpdate: (element, stateDiff, state) => { |
| if (stateDiff.width) |
| element.style.width = `${stateDiff.width}px`; |
| }, |
| }); |
| const scrollRef = REF.createRef({}); |
| const scrollEventStream = scrollRef.fromEvent('scroll'); |
| const resizeEventStream = new EventStream(); |
| window.addEventListener('resize', () => { |
| presenterRef.setState({resize:true}); |
| }); |
| const resizeContainerWidth = width => {containerRef.setState({width: width})}; |
| const getScrollableBoundingClientRect = () => scrollRef.element.getBoundingClientRect(); |
| const presenterRef = REF.createRef({ |
| state: {scrollLeft: 0}, |
| onElementMount: (element) => { |
| const scrollableWidth = getScrollableBoundingClientRect().width; |
| element.style.width = `${scrollableWidth}px`; |
| resizeEventStream.add(scrollableWidth); |
| }, |
| onStateUpdate: (element, stateDiff, state) => { |
| if (stateDiff.resize) { |
| const scrollableWidth = getScrollableBoundingClientRect().width; |
| element.style.width = `${scrollableWidth}px`; |
| resizeEventStream.add(scrollableWidth); |
| } |
| } |
| }); |
| const layoutSizeMayChange = new EventStream(); |
| layoutSizeMayChange.action(() => { |
| presenterRef.setState({resize:true}); |
| }); |
| // Provide parent functions/event to children to use |
| |
| return `<div class="content" ref="${scrollRef}"> |
| <div ref="${containerRef}" style="position: relative"> |
| <div ref="${presenterRef}" style="position: -webkit-sticky; position:sticky; top:0; left: 0">${ |
| ListProvider((updateChildrenFunctions) => { |
| if (exporter) { |
| exporter( |
| /** |
| * Update Children |
| * @param children {Array} r An array of the children |
| */ |
| (children) => { |
| updateChildrenFunctions(children); |
| // Propigate the current state to new children |
| resizeEventStream.replayLast(); |
| scrollEventStream.replayLast(); |
| }, |
| /** |
| * Notify Re-render |
| * @param width {number} r if undefined, it will auto detact the width change |
| */ |
| (width) => { |
| if (typeof width === "number" && width >= 0) |
| resizeEventStream.add(width); |
| else |
| layoutSizeMayChange.add(); |
| } |
| ); |
| } |
| }, [resizeContainerWidth, scrollEventStream, resizeEventStream, layoutSizeMayChange], ...childrenFunctions) |
| }</div> |
| </div> |
| </div>`; |
| } |
| |
| class ColorBatchRender { |
| constructor() { |
| this.colorSeqsMap = {}; |
| } |
| |
| lazyCreateColorSeqs(color, startAction, finalAction) { |
| if (false === color in this.colorSeqsMap) |
| this.colorSeqsMap[color] = [startAction, finalAction]; |
| } |
| |
| addSeq(color, seqAction) { |
| this.colorSeqsMap[color].push(seqAction); |
| } |
| |
| batchRender(context) { |
| for (let color of Object.keys(this.colorSeqsMap)) { |
| const seqs = this.colorSeqsMap[color]; |
| seqs[0](context, color); |
| for(let i = 2; i < seqs.length; i++) |
| seqs[i](context, color); |
| seqs[1](context, color); |
| } |
| } |
| clear() { |
| this.colorSeqsMap = new Map(); |
| } |
| } |
| |
| function xScrollStreamRenderFactory(height) { |
| return (redraw, element, stateDiff, state) => { |
| const width = typeof stateDiff.width === 'number' ? stateDiff.width : state.width; |
| if (width <= 0) |
| // Nothing to render |
| return; |
| let startX = 0; |
| let renderWidth = width; |
| requestAnimationFrame(() => { |
| if (element.logicWidth !== width) { |
| setupCanvasWidthWithDpr(element, width); |
| setupCanvasContextScale(element); |
| } |
| if (element.logicHeight !== height) { |
| setupCanvasHeightWithDpr(element, height); |
| setupCanvasContextScale(element); |
| } |
| element.getContext("2d", {alpha: false}).clearRect(startX, 0, renderWidth, element.logicHeight); |
| redraw(startX, renderWidth, element, stateDiff, state); |
| }); |
| } |
| } |
| |
| // namespace Timeline |
| const Timeline = {}; |
| |
| Timeline.CanvasSeriesComponent = (dots, scales, option = {}) => { |
| console.assert(dots.length <= scales.length); |
| |
| // Get the css value, this component assume to use with webkit.css |
| const computedStyle = getComputedStyle(document.body); |
| let radius = parseInt(computedStyle.getPropertyValue('--smallSize')) / 2; |
| let dotMargin = parseInt(computedStyle.getPropertyValue('--tinySize')) / 2; |
| let fontFamily = computedStyle.getPropertyValue('font-family'); |
| let defaultDotColor = computedStyle.getPropertyValue('--greenLight').trim(); |
| let defaultEmptyLineColor = computedStyle.getPropertyValue('--grey').trim(); |
| let defaultInnerLableColor = computedStyle.getPropertyValue('--white').trim(); |
| let defaultFontSize = 10; |
| |
| // Get configuration |
| // Default order is left is biggest |
| const reversed = typeof option.reversed === "boolean" ? option.reversed : false; |
| const getScale = typeof option.getScaleFunc === "function" ? option.getScaleFunc : (a) => a; |
| const comp = typeof option.compareFunc === "function" ? option.compareFunc : (a, b) => a - b; |
| const onDotClick = typeof option.onDotClick === "function" ? option.onDotClick : null; |
| const onDotEnter = typeof option.onDotEnter === "function" ? option.onDotEnter : null; |
| const onDotLeave = typeof option.onDotLeave === "function" ? option.onDotLeave : null; |
| const tagHeight = defaultFontSize; |
| const height = option.height ? option.height : 2 * radius + tagHeight; |
| const colorBatchRender = new ColorBatchRender(); |
| let drawLabelsSeqs = []; |
| |
| // Draw dot api can be used in user defined render function |
| const drawDot = (context, x, y, isEmpty, tag = null, innerLabel, useRadius, color, innerLabelColor, emptylineColor) => { |
| useRadius = useRadius ? useRadius : radius; |
| color = color ? color : defaultDotColor; |
| emptylineColor = emptylineColor ? emptylineColor : defaultEmptyLineColor; |
| innerLabelColor = innerLabelColor ? innerLabelColor : defaultInnerLableColor; |
| const fontSize = useRadius * 1.5; |
| const baselineY = y + useRadius; |
| if (!isEmpty) { |
| // Draw the dot |
| colorBatchRender.lazyCreateColorSeqs(color, (context) => { |
| context.beginPath(); |
| drawLabelsSeqs = []; |
| }, (context, color) => { |
| context.fillStyle = color; |
| context.fill(); |
| context.font = `${fontSize}px ${fontFamily}`; |
| context.textBaseline = "top"; |
| context.textAlign = "center"; |
| context.fontWeight = 400; |
| context.fillStyle = innerLabelColor; |
| drawLabelsSeqs.forEach(seq => seq()); |
| }); |
| colorBatchRender.addSeq(color, (context, color) => { |
| context.arc(x + dotMargin + useRadius, baselineY, useRadius, 0, 2 * Math.PI); |
| if (innerLabel instanceof Image || typeof innerLabel === "string" && (innerLabel.endsWith('.svg') || innerLabel.endsWith('.png') || innerLabel.endsWith('.jpg') || innerLabel.endsWith('.jpeg'))) { |
| let image = innerLabel; |
| if (typeof innerLabel === "string") { |
| image = new Image(); |
| image.src = innerLabel; |
| } |
| const image_padding = useRadius - fontSize / 2; |
| drawLabelsSeqs.push(() => { |
| context.drawImage(image, x + dotMargin + image_padding, y + image_padding, fontSize, fontSize); |
| }); |
| } else if (typeof innerLabel === "number" || typeof innerLabel === "string") { |
| drawLabelsSeqs.push(() => { |
| // Draw the inner label |
| const innerLabelSize = context.measureText(innerLabel); |
| const fontHeight = innerLabelSize.fontBoundingBoxAscent + innerLabelSize.fontBoundingBoxDescent; |
| const actualHeight = innerLabelSize.actualBoundingBoxAscent + innerLabelSize.actualBoundingBoxDescent; |
| const realStartGap = innerLabelSize.fontBoundingBoxAscent - innerLabelSize.actualBoundingBoxAscent; |
| context.fillText(innerLabel, x + dotMargin + useRadius, y - realStartGap + useRadius - (actualHeight < fontHeight ? actualHeight : fontHeight) / 2); |
| }); |
| } |
| }); |
| } else { |
| // Draw the empty |
| colorBatchRender.lazyCreateColorSeqs(emptylineColor, (context) => { |
| context.beginPath(); |
| }, (context, color) => { |
| context.strokeStyle = color; |
| context.stroke(); |
| }); |
| colorBatchRender.addSeq(emptylineColor, (context) => { |
| context.moveTo(x + dotMargin, baselineY); |
| context.lineTo(x + dotMargin + 2 * useRadius, baselineY); |
| context.lineWidth = 1; |
| }); |
| } |
| |
| // Draw the tag |
| if (typeof tag === "number" || typeof tag === "string") { |
| context.font = `${defaultFontSize}px ${fontFamily}`; |
| context.fillStyle = color; |
| context.textAlign = "center"; |
| context.textBaseline = "top"; |
| context.fillText(tag, x + dotMargin + radius, baselineY + useRadius); |
| } |
| |
| }; |
| const render = typeof option.renderFactory === "function" ? option.renderFactory(drawDot) : (dot, context, x, y) => drawDot(context, x, y, !dot); |
| const sortData = option.sortData === true ? option.sortData : false; |
| |
| // Initialize |
| const initDots = dots.map((dot) => dot); |
| const initScales = scales.map((scale) => scale); |
| if (sortData) { |
| initDots.sort((a, b) => comp(getScale(a), getScale(b))); |
| initScales.sort(comp); |
| } |
| const filterDots = (dots) => dots.filter(dot => typeof dot._x === 'number'); |
| let inCacheDots = []; |
| const getMouseEventTirggerDots = (e, scrollLeft, element) => { |
| const {x, y} = getMousePosInCanvas(e, element); |
| return inCacheDots.filter(dot => pointCircleCollisionDetact({x, y}, |
| { |
| x: dot._dotCenter.x - (scrollLeft - dot._cachedScrollLeft), |
| y: dot._dotCenter.y, |
| radius: radius |
| })); |
| } |
| |
| const dotWidth = 2 * (radius + dotMargin); |
| const padding = 100 * dotWidth / getDevicePixelRatio(); |
| const xScrollStreamRender = xScrollStreamRenderFactory(height); |
| |
| const redraw = (startX, renderWidth, element, stateDiff, state) => { |
| const scrollLeft = typeof stateDiff.scrollLeft === 'number' ? stateDiff.scrollLeft : state.scrollLeft; |
| const scales = stateDiff.scales ? stateDiff.scales : state.scales; |
| const dots = stateDiff.dots ? stateDiff.dots : state.dots; |
| // This color maybe change when switch dark/light mode |
| const defaultLineColor = getComputedStyle(document.body).getPropertyValue('--borderColorInlineElement'); |
| |
| const context = element.getContext("2d", { alpha: false }); |
| // Clear pervious batchRender |
| colorBatchRender.clear(); |
| // Draw the time line |
| colorBatchRender.lazyCreateColorSeqs(defaultLineColor, (context) => { |
| context.beginPath(); |
| }, (context, color) => { |
| context.lineWidth = 1; |
| context.strokeStyle = color; |
| context.stroke(); |
| }); |
| colorBatchRender.addSeq(defaultLineColor, (context) => { |
| context.moveTo(startX, radius); |
| context.lineTo(startX + renderWidth, radius); |
| }); |
| if (!scales || !dots || !scales.length || !dots.length) { |
| colorBatchRender.batchRender(context); |
| return; |
| } |
| // Draw the dots |
| // First, Calculate the render range: |
| let startScalesIndex = Math.floor((scrollLeft + startX) / dotWidth); |
| if (startScalesIndex < 0) |
| startScalesIndex = 0; |
| let endScalesIndex = startScalesIndex + Math.ceil((renderWidth) / dotWidth); |
| if (endScalesIndex >= scales.length) |
| endScalesIndex = scales.length - 1; |
| let currentDotIndex = 0; |
| for (let i = currentDotIndex; i <= startScalesIndex && currentDotIndex < dots.length; i++) { |
| const compResult = comp(scales[startScalesIndex], getScale(dots[currentDotIndex])); |
| if (!reversed) { |
| if (compResult > 0) |
| currentDotIndex ++; |
| else |
| break; |
| } else { |
| if (compResult < 0) |
| currentDotIndex ++; |
| else |
| break; |
| } |
| } |
| |
| // Use this to decrease colision search scope |
| inCacheDots = []; |
| for (let i = startScalesIndex; i <= endScalesIndex; i++) { |
| let x = i * dotWidth - scrollLeft; |
| if (currentDotIndex < dots.length && comp(scales[i], getScale(dots[currentDotIndex])) === 0) { |
| render(dots[currentDotIndex], context, x, 0); |
| dots[currentDotIndex]._dotCenter = {x: x + dotMargin + radius, y: radius}; |
| dots[currentDotIndex]._cachedScrollLeft = scrollLeft; |
| inCacheDots.push(dots[currentDotIndex]); |
| currentDotIndex += 1; |
| } else |
| render(null, context, x, 0); |
| } |
| colorBatchRender.batchRender(context); |
| }; |
| |
| return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => { |
| const mouseMove = (e) => { |
| let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, canvasRef.element); |
| if (dots.length) { |
| if (onDotEnter) { |
| dots[0].tipPoints = [ |
| {x: dots[0]._dotCenter.x, y: dots[0]._dotCenter.y - 3 * radius / 2}, |
| {x: dots[0]._dotCenter.x, y: dots[0]._dotCenter.y + radius / 2}, |
| ]; |
| onDotEnter(dots[0], e, canvasRef.element.getBoundingClientRect()); |
| } |
| canvasRef.element.style.cursor = "pointer"; |
| } else { |
| if (onDotLeave) |
| onDotLeave(e, canvasRef.element.getBoundingClientRect()); |
| canvasRef.element.style.cursor = "default"; |
| } |
| } |
| const onScrollAction = (e) => { |
| canvasRef.setState({scrollLeft: e.target.scrollLeft / getDevicePixelRatio()}); |
| mouseMove(e); |
| }; |
| const onResizeAction = (width) => { |
| canvasRef.setState({width: width}); |
| }; |
| |
| const canvasRef = REF.createRef({ |
| state: { |
| dots: initDots, |
| scales: initScales, |
| scrollLeft: 0, |
| width: 0, |
| onScreen: false, |
| }, |
| onElementMount: (element) => { |
| setupCanvasHeightWithDpr(element, height); |
| setupCanvasContextScale(element); |
| if (onDotClick) { |
| element.addEventListener('click', (e) => { |
| let dots = getMouseEventTirggerDots(e, canvasRef.state.scrollLeft, element); |
| if (dots.length) |
| onDotClick(dots[0], e); |
| }); |
| } |
| |
| if (onDotClick || onDotEnter || onDotLeave) |
| element.addEventListener('mousemove', mouseMove); |
| if (onDotLeave) |
| element.addEventListener('mouseleave', (e) => onDotLeave(e, element.getBoundingClientRect())); |
| |
| createInsertionObservers(element, (entries) => { |
| canvasRef.setState({onScreen: entries[0].isIntersecting}); |
| }, 0, 0.01, 0.01); |
| }, |
| onElementUnmount: (element) => { |
| onContainerScroll.stopAction(onScrollAction); |
| onResize.stopAction(onResizeAction); |
| // Clean the canvas, free its memory |
| element.width = 0; |
| element.height = 0; |
| }, |
| onStateUpdate: (element, stateDiff, state) => { |
| if (!state.onScreen && !stateDiff.onScreen) |
| return; |
| if (stateDiff.scales || stateDiff.dots || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number' || stateDiff.onScreen) { |
| if (stateDiff.scales) |
| stateDiff.scales = stateDiff.scales.map(x => x); |
| if (stateDiff.dots) |
| stateDiff.dots = stateDiff.dots.map(x => x); |
| xScrollStreamRender(redraw, element, stateDiff, state); |
| } |
| } |
| }); |
| |
| updateContainerWidth(scales.length * dotWidth * getDevicePixelRatio()); |
| const updateData = (dots, scales) => { |
| updateContainerWidth(scales.length * dotWidth * getDevicePixelRatio()); |
| canvasRef.setState({ |
| dots: dots, |
| scales: scales, |
| }); |
| }; |
| if (typeof option.exporter === "function") |
| option.exporter(updateData); |
| onContainerScroll.action(onScrollAction); |
| onResize.action(onResizeAction); |
| return `<div class="series"> |
| <canvas ref="${canvasRef}" width="0" height="0"> |
| </div>`; |
| }); |
| } |
| |
| Timeline.ExpandableSeriesComponent = (mainSeries, options, subSerieses, exporter) => { |
| let layoutSizeMayChangeEvent = null; |
| const ref = REF.createRef({ |
| state: {expanded: options.expanded ? options.expanded : false}, |
| onStateUpdate: (element, stateDiff) => { |
| if (stateDiff.expanded === false) { |
| element.children[0].style.display = 'none'; |
| element.children[1].style.display = 'block'; |
| element.children[2].style.display = 'none'; |
| } else if (stateDiff.expanded === true) { |
| element.children[0].style.display = 'block'; |
| element.children[1].style.display = 'none'; |
| element.children[2].style.display = 'block'; |
| } |
| // Notify inside of the provider that we may changed the layout size because of expanded / unexpanded. |
| layoutSizeMayChangeEvent.add(); |
| } |
| }); |
| if (exporter) |
| exporter((expanded) => ref.setState({expanded: expanded})); |
| return ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange) => { |
| layoutSizeMayChangeEvent = layoutSizeMayChange; |
| return `<div class="groupSeries" ref="${ref}"> |
| <div class="series" style="display:none;"></div> |
| <div>${mainSeries(updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange)}</div> |
| <div style="display:none">${subSerieses.map((subSeries) => subSeries(updateContainerWidth, onContainerScroll, onResize, layoutSizeMayChange)).join("")}</div> |
| </div>`; |
| }); |
| } |
| |
| Timeline.HeaderComponent = (label) => { |
| return `<div class="series">${label}</div>`; |
| } |
| |
| Timeline.ExpandableHeaderComponent = (mainLabel, options, subLabels, exporter) => { |
| const ref = REF.createRef({ |
| state: {expanded: options.expanded ? options.expanded : false}, |
| onStateUpdate: (element, stateDiff) => { |
| if (stateDiff.expanded === false) |
| element.children[1].style.display = "none"; |
| else if (stateDiff.expanded === true) |
| element.children[1].style.display = "block"; |
| } |
| }); |
| if (exporter) |
| exporter((expanded) => ref.setState({expanded: expanded})); |
| return `<div ref="${ref}"> |
| <div class="series"> |
| ${mainLabel} |
| </div> |
| <div style="display:none"> |
| ${subLabels.map(label => `<div class="series">${label}</div>`).join("")} |
| </div> |
| </div>`; |
| } |
| |
| Timeline.SeriesWithHeaderComponent = (header, series) => { |
| return {header, series}; |
| } |
| |
| Timeline.ExpandableSeriesWithHeaderExpanderComponent = (mainSeriesWithLable, options, ...subSeriesWithLable) => { |
| const ref = REF.createRef({ |
| state: {expanded: options.expanded ? options.expanded : false}, |
| onStateUpdate: (element, stateDiff) => { |
| if (stateDiff.expanded === false) |
| element.innerText = "+"; |
| else if (stateDiff.expanded === true) |
| element.innerText = "-"; |
| } |
| }); |
| const mainLabel = mainSeriesWithLable.header; |
| const subLabels = subSeriesWithLable.map(item => item.header); |
| const mainSeries = mainSeriesWithLable.series; |
| const subSerieses = subSeriesWithLable.map(item => item.series); |
| const expandedEvent = new EventStream(); |
| const clickEvent = ref.fromEvent('click').action(() => { |
| let expanded = ref.state.expanded; |
| expandedEvent.add(!expanded); |
| ref.setState({expanded: !expanded}); |
| }); |
| const composer = FP.composer(FP.currying((setHeaderExpand, setSeriesExpand) => { |
| expandedEvent.action((expanded) => { |
| setHeaderExpand(expanded); |
| setSeriesExpand(expanded); |
| }) |
| })); |
| return { |
| header: Timeline.ExpandableHeaderComponent(`<a class="link-button" href="javascript:void(0)" ref="${ref}">+</a>` + mainLabel, options, subLabels, composer), |
| series: Timeline.ExpandableSeriesComponent(mainSeries, options, subSerieses, composer), |
| } |
| } |
| |
| Timeline.CanvasXAxisComponent = (scales, option = {}) => { |
| // Get configuration |
| const getScaleKey = typeof option.getScaleFunc === "function" ? option.getScaleFunc : (a) => a; |
| const comp = typeof option.compareFunc === "function" ? option.compareFunc : (a, b) => b - a; |
| const onScaleClick = typeof option.onScaleClick === "function" ? option.onScaleClick : null; |
| const onScaleEnter = typeof option.onScaleEnter === "function" ? option.onScaleEnter : null; |
| const onScaleLeave = typeof option.onScaleLeave === "function" ? option.onScaleLeave : null; |
| const sortData = option.sortData === true ? option.sortData : false; |
| const getLabel = typeof option.getLabelFunc === "function" ? option.getLabelFunc : (a) => `${a}`; |
| const isTop = typeof option.isTop === "boolean" ? option.isTop : false; |
| |
| // Get the css value, this component assume to use with webkit.css |
| const computedStyle = getComputedStyle(document.body); |
| const fontFamily = computedStyle.getPropertyValue('font-family'); |
| const fontSize = computedStyle.getPropertyValue('--tinySize'); |
| const fontSizeNumber = parseInt(fontSize); |
| const fontColor = onScaleClick ? computedStyle.getPropertyValue('--linkColor') : computedStyle.getPropertyValue('color'); |
| const fontRotate = 60 * Math.PI / 180; |
| const fontTopRotate = 300 * Math.PI / 180; |
| const linkColor = computedStyle.getPropertyValue('--linkColor'); |
| const scaleWidth = parseInt(computedStyle.getPropertyValue('--smallSize')) + parseInt(computedStyle.getPropertyValue('--tinySize')); |
| const scaleTagLineHeight = parseInt(computedStyle.getPropertyValue('--smallSize')); |
| const scaleTagLinePadding = 10; |
| const scaleGroupTagLinePadding = 3; |
| const scaleGroupMargin = fontSizeNumber / 2; |
| const scaleBroadLineHeight = parseInt(computedStyle.getPropertyValue('--tinySize')) / 2; |
| const maxinumTextHeight = scaleWidth * 4.5; |
| const canvasHeight = typeof option.height === "number" ? option.height : parseInt(computedStyle.getPropertyValue('--smallSize')) * 5; |
| const sqrt3 = Math.sqrt(3); |
| const colorBatchRender = new ColorBatchRender(); |
| |
| const drawScale = (scaleLabel, group, context, x, y, isHoverable, lineColor, groupColor) => { |
| const computedStyle = getComputedStyle(document.body); |
| const usedLineColor = lineColor ? lineColor : computedStyle.getPropertyValue('--borderColorInlineElement'); |
| const usedGroupColor = groupColor ? groupColor : isDarkMode() ? computedStyle.getPropertyValue('--white') : computedStyle.getPropertyValue('--black'); |
| const totalWidth = group * scaleWidth; |
| const baseLineY = isTop ? y + canvasHeight - scaleBroadLineHeight : y + scaleBroadLineHeight; |
| const middlePointX = x + totalWidth / 2; |
| if (group > 1) { |
| const groupBaselineY = isTop ? baseLineY - scaleBroadLineHeight : baseLineY + scaleBroadLineHeight; |
| colorBatchRender.lazyCreateColorSeqs(usedGroupColor, (context) => { |
| context.beginPath(); |
| }, (context, color) => { |
| context.lineWidth = 1; |
| context.strokeStyle = color; |
| context.stroke(); |
| }); |
| colorBatchRender.addSeq(usedGroupColor, (context) => { |
| context.moveTo(x + scaleGroupMargin, groupBaselineY); |
| context.lineTo(x + scaleGroupMargin, baseLineY); |
| context.moveTo(x + scaleGroupMargin, groupBaselineY); |
| context.lineTo(x + totalWidth - scaleGroupMargin, groupBaselineY); |
| context.moveTo(x + totalWidth - scaleGroupMargin, groupBaselineY); |
| context.lineTo(x + totalWidth - scaleGroupMargin, baseLineY); |
| context.moveTo(middlePointX, groupBaselineY); |
| if (!isTop) |
| context.lineTo(middlePointX, groupBaselineY + scaleTagLineHeight - scaleTagLinePadding - scaleGroupTagLinePadding); |
| else |
| context.lineTo(middlePointX, groupBaselineY - scaleTagLineHeight + scaleTagLinePadding + scaleGroupTagLinePadding); |
| }); |
| } else { |
| colorBatchRender.lazyCreateColorSeqs(usedGroupColor, (context) => { |
| context.beginPath(); |
| }, (context, color) => { |
| context.lineWidth = 1; |
| context.strokeStyle = color; |
| context.stroke(); |
| }); |
| colorBatchRender.addSeq(usedGroupColor, (context) => { |
| context.moveTo(middlePointX, baseLineY); |
| if (!isTop) |
| context.lineTo(middlePointX, baseLineY + scaleTagLineHeight - scaleTagLinePadding); |
| else |
| context.lineTo(middlePointX, baseLineY - scaleTagLineHeight + scaleTagLinePadding); |
| }); |
| } |
| // Draw Tag |
| context.font = `${fontSize} ${fontFamily}`; |
| context.fillStyle = fontColor; |
| context.save(); |
| if (!isTop) { |
| context.translate(middlePointX, baseLineY + scaleTagLineHeight); |
| context.rotate(fontRotate); |
| context.translate(0 - middlePointX, 0 - baseLineY - scaleTagLineHeight); |
| context.fillText(getLabel(scaleLabel), middlePointX, baseLineY + scaleTagLineHeight); |
| } else { |
| context.translate(middlePointX, baseLineY - scaleTagLineHeight); |
| context.rotate(fontTopRotate); |
| context.translate(0 - middlePointX, 0 - baseLineY + scaleTagLineHeight); |
| context.fillText(getLabel(scaleLabel), middlePointX, baseLineY - scaleTagLineHeight); |
| } |
| context.restore(); |
| }; |
| const render = typeof option.renderFactory === "function" ? option.renderFactory(drawScale) : (scaleLabel, scaleGroup, context, x, y) => drawScale(scaleLabel, scaleGroup, context, x, y); |
| |
| const padding = 100 * scaleWidth / getDevicePixelRatio(); |
| const xScrollStreamRender = xScrollStreamRenderFactory(canvasHeight); |
| let onScreenScales = []; |
| |
| const getMouseEventTirggerScales = (e, scrollLeft, element) => { |
| const {x, y} = getMousePosInCanvas(e, element); |
| return onScreenScales.filter(scale => { |
| const labelLength = getLabel(scale.label).length; |
| const width = labelLength * fontSizeNumber / 2; |
| const height = labelLength * fontSizeNumber / 2 * sqrt3; |
| const point1 = { |
| x: scale._tagTop.x - scrollLeft - (isTop ? fontSizeNumber / 2 * sqrt3 : 0), |
| y: scale._tagTop.y + (fontSizeNumber / 2 + scaleTagLineHeight) * (isTop ? -1 : 1), |
| }; |
| const point2 = { |
| x: point1.x + fontSizeNumber / 2 * sqrt3, |
| y: scale._tagTop.y + scaleTagLineHeight * (isTop ? -1 : 1) |
| }; |
| const point3 = { |
| x: point2.x + width, |
| y: point2.y + height * (isTop ? -1 : 1), |
| }; |
| const point4 = { |
| x: point1.x + width, |
| y: point1.y + height * (isTop ? -1 : 1), |
| }; |
| return pointPolygonCollisionDetect({x, y}, [point1, point2, point3, point4]); |
| }); |
| }; |
| const redraw = (startX, renderWidth, element, stateDiff, state) => { |
| const scrollLeft = typeof stateDiff.scrollLeft === 'number' ? stateDiff.scrollLeft : state.scrollLeft; |
| const scales = stateDiff.scales ? stateDiff.scales : state.scales; |
| const scalesMapLinkList = stateDiff.scalesMapLinkList ? stateDiff.scalesMapLinkList : state.scalesMapLinkList; |
| const width = typeof stateDiff.width === 'number' ? stateDiff.width : state.width; |
| const usedLineColor = computedStyle.getPropertyValue('--borderColorInlineElement'); |
| const baseLineY = isTop ? canvasHeight - scaleBroadLineHeight : scaleBroadLineHeight; |
| const context = element.getContext("2d", { alpha: false }); |
| // Clear pervious batch render |
| colorBatchRender.clear(); |
| colorBatchRender.lazyCreateColorSeqs(usedLineColor, (context) => { |
| context.beginPath(); |
| }, (context, color) => { |
| context.lineWidth = 1; |
| context.strokeStyle = color; |
| context.stroke(); |
| }); |
| colorBatchRender.addSeq(usedLineColor, (context) => { |
| context.moveTo(0, baseLineY); |
| context.lineTo(element.logicWidth, baseLineY); |
| }); |
| if (!scales || !scales.length) { |
| colorBatchRender.batchRender(context); |
| return; |
| } |
| let currentStartScaleIndex = Math.floor(scrollLeft / scaleWidth); |
| if (currentStartScaleIndex < 0) |
| currentStartScaleIndex = 0; |
| const currentStartScaleKey = getScaleKey(scales[currentStartScaleIndex]); |
| let currentEndScaleIndex = Math.ceil((scrollLeft + renderWidth) / scaleWidth); |
| currentEndScaleIndex = currentEndScaleIndex >= scales.length ? scales.length - 1 : currentEndScaleIndex; |
| const currentEndScaleKey = getScaleKey(scales[currentEndScaleIndex]); |
| const currentStartNode = scalesMapLinkList.map.get(currentStartScaleKey); |
| const currentEndNode = scalesMapLinkList.map.get(currentEndScaleKey); |
| if (!currentEndNode) { |
| console.error(currentEndScaleKey); |
| } |
| let now = currentStartNode; |
| |
| |
| onScreenScales = []; |
| while (now != currentEndNode.next) { |
| const label = now.label; |
| const group = now.group; |
| render(label, group, context, now.x - scrollLeft, 0); |
| now._tagTop = {x: now.x + group * scaleWidth / 2, y: isTop ? canvasHeight - scaleBroadLineHeight : scaleBroadLineHeight}; |
| onScreenScales.push(now); |
| now = now.next; |
| } |
| colorBatchRender.batchRender(context); |
| }; |
| |
| // Initialize |
| // Do a copy, sorting will not have side effect |
| const initScales = scales.map((item) => item); |
| if (sortData) |
| initScales.sort(comp); |
| |
| const getScalesMapLinkList = (scales) => { |
| const res = { |
| map: new Map(), |
| linkListHead: {next: null, group: null} |
| }; |
| let now = res.linkListHead; |
| let currentX = 0; |
| scales.forEach((scale) => { |
| let key = getScaleKey(scale); |
| if (res.map.has(key)) |
| res.map.get(key).group += 1; |
| else { |
| now.next = {next: null, group: 1, label: scale, x: currentX}; |
| now = now.next; |
| res.map.set(key, now); |
| } |
| currentX += scaleWidth; |
| }); |
| return res; |
| }; |
| const initScaleGroupMapLinkList = getScalesMapLinkList(initScales); |
| |
| |
| return { |
| series: ListProviderReceiver((updateContainerWidth, onContainerScroll, onResize) => { |
| const mouseMove = (e) => { |
| let scales = getMouseEventTirggerScales(e, canvasRef.state.scrollLeft, canvasRef.element); |
| if (scales.length) { |
| if (onScaleEnter) { |
| const labelLength = getLabel(scales[0].label).length; |
| scales[0].tipPoints = [{ |
| x: scales[0]._tagTop.x - canvasRef.state.scrollLeft, |
| y: scales[0]._tagTop.y + scaleTagLineHeight * (isTop ? -1 : 0), |
| }, { |
| x: scales[0]._tagTop.x - canvasRef.state.scrollLeft + labelLength * fontSizeNumber / 3 - scaleTagLineHeight * (isTop ? 1 : .25), |
| y: scales[0]._tagTop.y + (labelLength * fontSizeNumber / 2 * sqrt3) * (isTop ? -1 : 1) + scaleTagLineHeight * (isTop ? 1 : 0), |
| }]; |
| onScaleEnter(scales[0], e, canvasRef.element.getBoundingClientRect()); |
| } |
| canvasRef.element.style.cursor = "pointer"; |
| } else { |
| if (onScaleEnter) |
| onScaleLeave(e, canvasRef.element.getBoundingClientRect()); |
| canvasRef.element.style.cursor = "default"; |
| } |
| } |
| const onScrollAction = (e) => { |
| canvasRef.setState({scrollLeft: e.target.scrollLeft / getDevicePixelRatio()}); |
| mouseMove(e); |
| }; |
| const onResizeAction = (width) => { |
| canvasRef.setState({width: width}); |
| }; |
| |
| const canvasRef = REF.createRef({ |
| state: { |
| scrollLeft: 0, |
| width: 0, |
| scales: initScales, |
| scalesMapLinkList: initScaleGroupMapLinkList |
| }, |
| onElementMount: (element) => { |
| setupCanvasHeightWithDpr(element, canvasHeight); |
| setupCanvasContextScale(element); |
| if (onScaleClick) { |
| element.addEventListener('click', (e) => { |
| let scales = getMouseEventTirggerScales(e, canvasRef.state.scrollLeft, element); |
| if (scales.length) |
| onScaleClick(scales[0], e); |
| }); |
| } |
| |
| if (onScaleClick || onScaleEnter || onScaleLeave) |
| element.addEventListener('mousemove', mouseMove); |
| if (onScaleLeave) |
| element.addEventListener('mouseleave', (e) => onScaleLeave(e, element.getBoundingClientRect())); |
| }, |
| onElementUnmount: (element) => { |
| onContainerScroll.stopAction(onScrollAction); |
| onResize.stopAction(onResizeAction); |
| }, |
| onStateUpdate: (element, stateDiff, state) => { |
| if (stateDiff.scales || typeof stateDiff.scrollLeft === 'number' || typeof stateDiff.width === 'number') { |
| xScrollStreamRender(redraw, element, stateDiff, state); |
| } |
| } |
| }); |
| |
| updateContainerWidth(scales.length * scaleWidth * getDevicePixelRatio()); |
| const updateData = (scales) => { |
| // In case of modification while rendering |
| const scalesCopy = scales.map(x => x); |
| updateContainerWidth(scalesCopy.length * scaleWidth * getDevicePixelRatio()); |
| canvasRef.setState({ |
| scales: scalesCopy, |
| scalesMapLinkList: getScalesMapLinkList(scalesCopy) |
| }); |
| } |
| if (typeof option.exporter === "function") |
| option.exporter(updateData); |
| onContainerScroll.action(onScrollAction); |
| onResize.action(onResizeAction); |
| return `<div class="x-axis"> |
| <canvas ref="${canvasRef}"> |
| </div>`; |
| }), |
| isAxis: true, // Mark self as an axis, |
| height: canvasHeight, // Expose Height to parent |
| }; |
| } |
| |
| Timeline.CanvasContainer = (exporter, ...children) => { |
| let headerAxisPlaceHolderHeight = 0; |
| let topAxis = true; |
| const upackChildren = (children) => { |
| const headers = []; |
| const serieses = []; |
| children.forEach(child => { |
| if (false === "series" in child) { |
| console.error("Please use Timeline.SeriesWithHeaderComponent or Timeline.ExpandableSeriesWithHeaderExpanderComponent or Timeline.CanvasXAxisComponent as children"); |
| return; |
| } |
| if (child.header) |
| headers.push(child.header); |
| serieses.push(child.series); |
| if (child.isAxis && topAxis) |
| headerAxisPlaceHolderHeight += child.height; |
| else if (topAxis) |
| topAxis = false; |
| }); |
| return {headers, serieses}; |
| }; |
| const {headers, serieses} = upackChildren(children); |
| let composer = FP.composer(FP.currying((updateHeaders, updateSerieses, notifyRerender) => { |
| if (exporter) |
| exporter((newChildren) => { |
| const {headers, serieses} = upackChildren(newChildren); |
| updateHeaders(headers); |
| updateSerieses(serieses); |
| }, notifyRerender); |
| })); |
| return ( |
| `<div class="timeline"> |
| <div class="header" style="padding-top:${headerAxisPlaceHolderHeight}px"> |
| ${ListComponent(composer, ...headers)} |
| </div> |
| ${XScrollableCanvasProvider(composer, ...serieses)} |
| </div>` |
| ); |
| } |
| |
| export { |
| Timeline |
| }; |