vargburz
3 years ago
17 changed files with 5757 additions and 2 deletions
@ -0,0 +1,201 @@
|
||||
Apache License |
||||
Version 2.0, January 2004 |
||||
http://www.apache.org/licenses/ |
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION |
||||
|
||||
1. Definitions. |
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, |
||||
and distribution as defined by Sections 1 through 9 of this document. |
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by |
||||
the copyright owner that is granting the License. |
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all |
||||
other entities that control, are controlled by, or are under common |
||||
control with that entity. For the purposes of this definition, |
||||
"control" means (i) the power, direct or indirect, to cause the |
||||
direction or management of such entity, whether by contract or |
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the |
||||
outstanding shares, or (iii) beneficial ownership of such entity. |
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity |
||||
exercising permissions granted by this License. |
||||
|
||||
"Source" form shall mean the preferred form for making modifications, |
||||
including but not limited to software source code, documentation |
||||
source, and configuration files. |
||||
|
||||
"Object" form shall mean any form resulting from mechanical |
||||
transformation or translation of a Source form, including but |
||||
not limited to compiled object code, generated documentation, |
||||
and conversions to other media types. |
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or |
||||
Object form, made available under the License, as indicated by a |
||||
copyright notice that is included in or attached to the work |
||||
(an example is provided in the Appendix below). |
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object |
||||
form, that is based on (or derived from) the Work and for which the |
||||
editorial revisions, annotations, elaborations, or other modifications |
||||
represent, as a whole, an original work of authorship. For the purposes |
||||
of this License, Derivative Works shall not include works that remain |
||||
separable from, or merely link (or bind by name) to the interfaces of, |
||||
the Work and Derivative Works thereof. |
||||
|
||||
"Contribution" shall mean any work of authorship, including |
||||
the original version of the Work and any modifications or additions |
||||
to that Work or Derivative Works thereof, that is intentionally |
||||
submitted to Licensor for inclusion in the Work by the copyright owner |
||||
or by an individual or Legal Entity authorized to submit on behalf of |
||||
the copyright owner. For the purposes of this definition, "submitted" |
||||
means any form of electronic, verbal, or written communication sent |
||||
to the Licensor or its representatives, including but not limited to |
||||
communication on electronic mailing lists, source code control systems, |
||||
and issue tracking systems that are managed by, or on behalf of, the |
||||
Licensor for the purpose of discussing and improving the Work, but |
||||
excluding communication that is conspicuously marked or otherwise |
||||
designated in writing by the copyright owner as "Not a Contribution." |
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity |
||||
on behalf of whom a Contribution has been received by Licensor and |
||||
subsequently incorporated within the Work. |
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of |
||||
this License, each Contributor hereby grants to You a perpetual, |
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||
copyright license to reproduce, prepare Derivative Works of, |
||||
publicly display, publicly perform, sublicense, and distribute the |
||||
Work and such Derivative Works in Source or Object form. |
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of |
||||
this License, each Contributor hereby grants to You a perpetual, |
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||
(except as stated in this section) patent license to make, have made, |
||||
use, offer to sell, sell, import, and otherwise transfer the Work, |
||||
where such license applies only to those patent claims licensable |
||||
by such Contributor that are necessarily infringed by their |
||||
Contribution(s) alone or by combination of their Contribution(s) |
||||
with the Work to which such Contribution(s) was submitted. If You |
||||
institute patent litigation against any entity (including a |
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work |
||||
or a Contribution incorporated within the Work constitutes direct |
||||
or contributory patent infringement, then any patent licenses |
||||
granted to You under this License for that Work shall terminate |
||||
as of the date such litigation is filed. |
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the |
||||
Work or Derivative Works thereof in any medium, with or without |
||||
modifications, and in Source or Object form, provided that You |
||||
meet the following conditions: |
||||
|
||||
(a) You must give any other recipients of the Work or |
||||
Derivative Works a copy of this License; and |
||||
|
||||
(b) You must cause any modified files to carry prominent notices |
||||
stating that You changed the files; and |
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works |
||||
that You distribute, all copyright, patent, trademark, and |
||||
attribution notices from the Source form of the Work, |
||||
excluding those notices that do not pertain to any part of |
||||
the Derivative Works; and |
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its |
||||
distribution, then any Derivative Works that You distribute must |
||||
include a readable copy of the attribution notices contained |
||||
within such NOTICE file, excluding those notices that do not |
||||
pertain to any part of the Derivative Works, in at least one |
||||
of the following places: within a NOTICE text file distributed |
||||
as part of the Derivative Works; within the Source form or |
||||
documentation, if provided along with the Derivative Works; or, |
||||
within a display generated by the Derivative Works, if and |
||||
wherever such third-party notices normally appear. The contents |
||||
of the NOTICE file are for informational purposes only and |
||||
do not modify the License. You may add Your own attribution |
||||
notices within Derivative Works that You distribute, alongside |
||||
or as an addendum to the NOTICE text from the Work, provided |
||||
that such additional attribution notices cannot be construed |
||||
as modifying the License. |
||||
|
||||
You may add Your own copyright statement to Your modifications and |
||||
may provide additional or different license terms and conditions |
||||
for use, reproduction, or distribution of Your modifications, or |
||||
for any such Derivative Works as a whole, provided Your use, |
||||
reproduction, and distribution of the Work otherwise complies with |
||||
the conditions stated in this License. |
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, |
||||
any Contribution intentionally submitted for inclusion in the Work |
||||
by You to the Licensor shall be under the terms and conditions of |
||||
this License, without any additional terms or conditions. |
||||
Notwithstanding the above, nothing herein shall supersede or modify |
||||
the terms of any separate license agreement you may have executed |
||||
with Licensor regarding such Contributions. |
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade |
||||
names, trademarks, service marks, or product names of the Licensor, |
||||
except as required for reasonable and customary use in describing the |
||||
origin of the Work and reproducing the content of the NOTICE file. |
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or |
||||
agreed to in writing, Licensor provides the Work (and each |
||||
Contributor provides its Contributions) on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
||||
implied, including, without limitation, any warranties or conditions |
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A |
||||
PARTICULAR PURPOSE. You are solely responsible for determining the |
||||
appropriateness of using or redistributing the Work and assume any |
||||
risks associated with Your exercise of permissions under this License. |
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, |
||||
whether in tort (including negligence), contract, or otherwise, |
||||
unless required by applicable law (such as deliberate and grossly |
||||
negligent acts) or agreed to in writing, shall any Contributor be |
||||
liable to You for damages, including any direct, indirect, special, |
||||
incidental, or consequential damages of any character arising as a |
||||
result of this License or out of the use or inability to use the |
||||
Work (including but not limited to damages for loss of goodwill, |
||||
work stoppage, computer failure or malfunction, or any and all |
||||
other commercial damages or losses), even if such Contributor |
||||
has been advised of the possibility of such damages. |
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing |
||||
the Work or Derivative Works thereof, You may choose to offer, |
||||
and charge a fee for, acceptance of support, warranty, indemnity, |
||||
or other liability obligations and/or rights consistent with this |
||||
License. However, in accepting such obligations, You may act only |
||||
on Your own behalf and on Your sole responsibility, not on behalf |
||||
of any other Contributor, and only if You agree to indemnify, |
||||
defend, and hold each Contributor harmless for any liability |
||||
incurred by, or claims asserted against, such Contributor by reason |
||||
of your accepting any such warranty or additional liability. |
||||
|
||||
END OF TERMS AND CONDITIONS |
||||
|
||||
APPENDIX: How to apply the Apache License to your work. |
||||
|
||||
To apply the Apache License to your work, attach the following |
||||
boilerplate notice, with the fields enclosed by brackets "[]" |
||||
replaced with your own identifying information. (Don't include |
||||
the brackets!) The text should be enclosed in the appropriate |
||||
comment syntax for the file format. We also recommend that a |
||||
file or class name and description of purpose be included on the |
||||
same "printed page" as the copyright notice for easier |
||||
identification within third-party archives. |
||||
|
||||
Copyright [yyyy] [name of copyright owner] |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
@ -0,0 +1,35 @@
|
||||
const path = require('path'); |
||||
|
||||
|
||||
function resolve(dir) { |
||||
return path.join(__dirname, '..', dir) |
||||
} |
||||
|
||||
module.exports = { |
||||
context: resolve('src'), |
||||
entry: './index.ts', |
||||
plugins: [], |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
test: /\.ts$/, |
||||
use: 'ts-loader', |
||||
exclude: /node_modules/ |
||||
}, |
||||
{ |
||||
test: /\.css$/, |
||||
use: ['style-loader', 'css-loader'], |
||||
exclude: /node_modules/ |
||||
} |
||||
], |
||||
}, |
||||
resolve: { |
||||
extensions: ['.ts', '.js'], |
||||
}, |
||||
output: { |
||||
filename: 'index.js', |
||||
path: resolve('dist'), |
||||
libraryTarget: 'umd', |
||||
umdNamedDefine: true |
||||
} |
||||
}; |
@ -0,0 +1,8 @@
|
||||
const baseWebpackConfig = require('./webpack.base.conf'); |
||||
|
||||
var conf = baseWebpackConfig; |
||||
conf.devtool = 'inline-source-map'; |
||||
conf.watch = true; |
||||
conf.mode = 'development'; |
||||
|
||||
module.exports = conf; |
@ -0,0 +1,6 @@
|
||||
const baseWebpackConfig = require('./webpack.base.conf'); |
||||
|
||||
var conf = baseWebpackConfig; |
||||
conf.mode = 'production'; |
||||
|
||||
module.exports = baseWebpackConfig; |
@ -0,0 +1,99 @@
|
||||
import { ChartwerkPod, TickOrientation, TimeFormat } from '@chartwerk/core'; |
||||
import { LineTimeSerie, LineOptions, Mode } from './types'; |
||||
export declare class ChartwerkLineChart extends ChartwerkPod<LineTimeSerie, LineOptions> { |
||||
lineGenerator: any; |
||||
metricContainer: any; |
||||
constructor(_el: HTMLElement, _series?: LineTimeSerie[], _options?: LineOptions); |
||||
renderMetrics(): void; |
||||
initLineGenerator(): void; |
||||
appendData(data: [number, number][]): void; |
||||
_renderDots(datapoints: number[][], serieIdx: number): void; |
||||
_renderLines(datapoints: number[][], serieIdx: number): void; |
||||
_renderMetric(datapoints: number[][], metricOptions: { |
||||
color: string; |
||||
confidence: number; |
||||
target: string; |
||||
mode: Mode; |
||||
serieIdx: number; |
||||
renderDots: boolean; |
||||
renderLines: boolean; |
||||
}): void; |
||||
updateCrosshair(): void; |
||||
appendCrosshairCircles(): void; |
||||
appendCrosshairCircle(serieIdx: number): void; |
||||
renderSharedCrosshair(values: { |
||||
x?: number; |
||||
y?: number; |
||||
}): void; |
||||
hideSharedCrosshair(): void; |
||||
moveCrosshairLine(xPosition: number, yPosition: number): void; |
||||
moveCrosshairCircle(xPosition: number, yPosition: number, serieIdx: number): void; |
||||
hideCrosshairCircle(serieIdx: number): void; |
||||
getClosestDatapoint(serie: LineTimeSerie, xValue: number, yValue: number): [number, number]; |
||||
getClosestIndex(datapoints: [number, number][], xValue: number, yValue: number): number; |
||||
getValueInterval(columnIdx: number): number | undefined; |
||||
onMouseMove(): void; |
||||
findAndHighlightDatapoints(xValue: number, yValue: number): { |
||||
value: [number, number]; |
||||
color: string; |
||||
label: string; |
||||
}[]; |
||||
isOutOfRange(closestDatapoint: [number, number], xValue: number, yValue: number, useOutOfRange?: boolean): boolean; |
||||
onMouseOver(): void; |
||||
onMouseOut(): void; |
||||
} |
||||
export declare const VueChartwerkLineChartObject: { |
||||
render(createElement: any): any; |
||||
mixins: { |
||||
props: { |
||||
id: { |
||||
type: StringConstructor; |
||||
required: boolean; |
||||
}; |
||||
series: { |
||||
type: ArrayConstructor; |
||||
required: boolean; |
||||
default: () => any[]; |
||||
}; |
||||
options: { |
||||
type: ObjectConstructor; |
||||
required: boolean; |
||||
default: () => {}; |
||||
}; |
||||
}; |
||||
watch: { |
||||
id(): void; |
||||
series(): void; |
||||
options(): void; |
||||
}; |
||||
mounted(): void; |
||||
destroyed(): void; |
||||
methods: { |
||||
render(): void; |
||||
renderSharedCrosshair(values: { |
||||
x?: number; |
||||
y?: number; |
||||
}): void; |
||||
hideSharedCrosshair(): void; |
||||
onPanningRescale(event: any): void; |
||||
renderChart(): void; |
||||
appendEvents(): void; |
||||
zoomIn(range: any): void; |
||||
zoomOut(centers: any): void; |
||||
mouseMove(evt: any): void; |
||||
mouseOut(): void; |
||||
onLegendClick(idx: any): void; |
||||
panningEnd(range: any): void; |
||||
panning(range: any): void; |
||||
contextMenu(evt: any): void; |
||||
sharedCrosshairMove(event: any): void; |
||||
renderEnd(): void; |
||||
}; |
||||
}[]; |
||||
methods: { |
||||
render(): void; |
||||
renderSharedCrosshair(values: any): void; |
||||
hideSharedCrosshair(): void; |
||||
}; |
||||
}; |
||||
export { LineTimeSerie, LineOptions, Mode, TickOrientation, TimeFormat }; |
File diff suppressed because one or more lines are too long
@ -0,0 +1,16 @@
|
||||
import { TimeSerie, Options } from '@chartwerk/core'; |
||||
declare type LineTimeSerieParams = { |
||||
confidence: number; |
||||
mode: Mode; |
||||
maxLength: number; |
||||
renderDots: boolean; |
||||
renderLines: boolean; |
||||
useOutOfRange: boolean; |
||||
}; |
||||
export declare enum Mode { |
||||
STANDARD = "Standard", |
||||
CHARGE = "Charge" |
||||
} |
||||
export declare type LineTimeSerie = TimeSerie & Partial<LineTimeSerieParams>; |
||||
export declare type LineOptions = Options; |
||||
export {}; |
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type"> |
||||
<meta content="utf-8" http-equiv="encoding"> |
||||
|
||||
<script src="../dist/index.js" type="text/javascript"></script> |
||||
</head> |
||||
<body> |
||||
<div id="chart" style="width: 500px; height: 500px;"></div> |
||||
|
||||
<script type="text/javascript"> |
||||
const startTime = 1590590148; |
||||
const arrayLength = 20; |
||||
const data1 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 40), startTime + idx * 10000]); |
||||
const data2 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 100), startTime + idx * 10000]); |
||||
const data3 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 20) + 90, startTime + idx * 10000]); |
||||
const zoomIn = (ranges) => { const range = ranges[0]; options.axis.x.range = range; pod.updateData(undefined, options) } |
||||
const zoomOut = (ranges) => { console.log('zoomout'); options.axis.x.range = undefined; pod.updateData(undefined, options) } |
||||
let options = { |
||||
renderLegend: false, usePanning: false, axis: { y: { invert: false, range: [0, 350] }, x: { format: 'time' } }, |
||||
eventsCallbacks: { zoomIn: zoomIn, zoomOut } |
||||
} |
||||
var pod = new ChartwerkLineChart( |
||||
document.getElementById('chart'), |
||||
[ |
||||
{ target: 'test1', datapoints: data1, color: 'green' }, |
||||
{ target: 'test2', datapoints: data2, color: 'blue' }, |
||||
{ target: 'test3', datapoints: data3, color: 'orange' }, |
||||
], |
||||
options |
||||
); |
||||
pod.render(); |
||||
</script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html> |
||||
<html> |
||||
|
||||
<head> |
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type"> |
||||
<meta content="utf-8" http-equiv="encoding"> |
||||
|
||||
<script src="../dist/index.js" type="text/javascript"></script> |
||||
</head> |
||||
|
||||
<body> |
||||
<div id="chart" style="width: 500px; height: 500px;"></div> |
||||
|
||||
<script type="text/javascript"> |
||||
const startTime = 1590590148; |
||||
const arrayLength = 100; |
||||
const data1 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 40), startTime + idx * 10000]); |
||||
const data2 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 100), startTime + idx * 10000]); |
||||
const data3 = Array.from({ length: arrayLength }, (el, idx) => [Math.floor(Math.random() * 20) + 90, startTime + idx * 10000]); |
||||
let options = { renderLegend: false, usePanning: false, axis: { y: { invert: false, range: [0, 350] }, x: { format: 'time' } } }; |
||||
var pod = new ChartwerkLineChart( |
||||
document.getElementById('chart'), |
||||
[ |
||||
{ target: 'test1', datapoints: data1, color: 'green', maxLength: arrayLength + 30, renderDots: true }, |
||||
{ target: 'test2', datapoints: data2, color: 'blue', maxLength: arrayLength + 30, renderDots: true }, |
||||
{ target: 'test3', datapoints: data3, color: 'orange', maxLength: arrayLength + 30, renderDots: true }, |
||||
], |
||||
options |
||||
); |
||||
pod.render(); |
||||
let rerenderIdx = arrayLength; |
||||
var test = setInterval(() => { |
||||
rerenderIdx += 1; |
||||
const d1 = [Math.floor(Math.random() * 40), startTime + rerenderIdx * 10000]; |
||||
const d2 = [Math.floor(Math.random() * 100), startTime + rerenderIdx * 10000]; |
||||
const d3 = [Math.floor(Math.random() * 20) + 90, startTime + rerenderIdx * 10000]; |
||||
|
||||
console.time('rerender'); |
||||
pod.appendData([d1, d2, d3]); |
||||
console.timeEnd('rerender'); |
||||
if(rerenderIdx > arrayLength + 100) { |
||||
clearInterval(test); |
||||
} |
||||
}, 1000); |
||||
</script> |
||||
</body> |
||||
|
||||
</html> |
@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type"> |
||||
<meta content="utf-8" http-equiv="encoding"> |
||||
|
||||
<script src="../dist/index.js" type="text/javascript"></script> |
||||
</head> |
||||
<body> |
||||
<div id="chart" style="width: 50%; height: 500px;"></div> |
||||
|
||||
<script type="text/javascript"> |
||||
const startTime = 1590590148; |
||||
const arrayLength = 20; |
||||
const data1 = createDatapoints(arrayLength, startTime, 40); |
||||
const data2 = createDatapoints(arrayLength, startTime, 30, 50); |
||||
const data3 = createDatapoints(arrayLength, startTime, 20, 90); |
||||
|
||||
let options = { |
||||
renderLegend: false, |
||||
axis: { |
||||
y: { invert: true, valueFormatter: timeValueFormatter, format: 'custom', colorFormatter: colorFormatter }, |
||||
x: { format: 'numeric' } |
||||
}, |
||||
zoomEvents: { |
||||
mouse: { |
||||
pan: { isActive: true, orientation: 'vertical', keyEvent: 'shift' }, |
||||
zoom: { isActive: true, orientation: 'vertical', keyEvent: 'main' } |
||||
}, |
||||
scroll: { |
||||
pan: { isActive: true, orientation: 'vertical', keyEvent: 'main' }, |
||||
zoom: { isActive: false, keyEvent: 'shift' } |
||||
} |
||||
}, |
||||
eventsCallbacks: { zoomIn: zoomIn, zoomOut, panning: onPanning, }, |
||||
crosshair: { orientation: 'horizontal' }, |
||||
margin: { top: 30, right: 20, bottom: 20, left: 50 } |
||||
} |
||||
var pod = new ChartwerkLineChart( |
||||
document.getElementById('chart'), |
||||
[ |
||||
{ target: 'test1', datapoints: data1, color: 'green' }, |
||||
{ target: 'test2', datapoints: data2, color: 'blue' }, |
||||
{ target: 'test3', datapoints: data3, color: 'orange' }, |
||||
], |
||||
options |
||||
); |
||||
pod.render(); |
||||
|
||||
|
||||
function zoomIn(ranges) { |
||||
const range = ranges[1]; |
||||
options.axis.y.range = range; |
||||
pod.updateData(undefined, options); |
||||
} |
||||
|
||||
function zoomOut() { |
||||
options.axis.y.range = undefined; |
||||
console.log('zoomOut', pod.updateData); |
||||
pod.updateData(undefined, options) |
||||
} |
||||
|
||||
function onPanning() { |
||||
console.log('panning', pod); |
||||
} |
||||
|
||||
function createDatapoints(arrayLength, startTime, randomValue, randomOffset = 0) { |
||||
return Array.from({ length: arrayLength }, (el, idx) => [ |
||||
startTime + idx * 10000, // y axis |
||||
Math.floor(Math.random() * randomValue) + randomOffset // x axis |
||||
]); |
||||
} |
||||
|
||||
function timeValueFormatter(value) { |
||||
const date = new Date(value); |
||||
const hours = date.getHours(); |
||||
const minutes = '0' + date.getMinutes(); |
||||
const seconds = '0' + date.getSeconds(); |
||||
return hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); |
||||
} |
||||
|
||||
function colorFormatter(value, index) { |
||||
if(index % 2 === 0) { |
||||
return 'red'; |
||||
} |
||||
return 'black'; |
||||
} |
||||
</script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,32 @@
|
||||
{ |
||||
"name": "@chartwerk/line-chart", |
||||
"version": "0.2.4", |
||||
"description": "Chartwerk line chart", |
||||
"main": "dist/index.js", |
||||
"scripts": { |
||||
"build": "webpack --config build/webpack.prod.conf.js", |
||||
"dev": "webpack --config build/webpack.dev.conf.js", |
||||
"test": "echo \"Error: no test specified\" && exit 1" |
||||
}, |
||||
"repository": { |
||||
"type": "git", |
||||
"url": "https://github.com/chartwerk/line-chart.git" |
||||
}, |
||||
"author": "CorpGlory", |
||||
"license": "Apache-2.0", |
||||
"dependencies": { |
||||
"@chartwerk/core": "github:chartwerk/core#6e41013932bf16b05da2bc2757bcfaacba3cdb21" |
||||
}, |
||||
"devDependencies": { |
||||
"@types/d3": "5.16.4", |
||||
"@types/lodash": "^4.14.149", |
||||
"css-loader": "^3.4.2", |
||||
"d3": "5.16.0", |
||||
"lodash": "^4.17.15", |
||||
"style-loader": "^1.1.3", |
||||
"ts-loader": "^6.2.1", |
||||
"typescript": "^3.8.3", |
||||
"webpack": "^4.42.0", |
||||
"webpack-cli": "^3.3.11" |
||||
} |
||||
} |
@ -0,0 +1,522 @@
|
||||
import { ChartwerkPod, VueChartwerkPodMixin, TickOrientation, TimeFormat, CrosshairOrientation } from '@chartwerk/core'; |
||||
import { LineTimeSerie, LineOptions, Mode } from './types'; |
||||
|
||||
import * as d3 from 'd3'; |
||||
import * as _ from 'lodash'; |
||||
|
||||
const METRIC_CIRCLE_RADIUS = 1.5; |
||||
const CROSSHAIR_CIRCLE_RADIUS = 3; |
||||
const CROSSHAIR_BACKGROUND_RAIDUS = 9; |
||||
const CROSSHAIR_BACKGROUND_OPACITY = 0.3; |
||||
|
||||
export class ChartwerkLineChart extends ChartwerkPod<LineTimeSerie, LineOptions> { |
||||
lineGenerator = null; |
||||
metricContainer = null; |
||||
|
||||
constructor(_el: HTMLElement, _series: LineTimeSerie[] = [], _options: LineOptions = {}) { |
||||
super(d3, _el, _series, _options); |
||||
} |
||||
|
||||
renderMetrics(): void { |
||||
this.updateCrosshair(); |
||||
this.initLineGenerator(); |
||||
|
||||
// TODO: seems that renderMetrics is not correct name
|
||||
if(this.series.length === 0) { |
||||
this.renderNoDataPointsMessage(); |
||||
return; |
||||
} |
||||
|
||||
// TODO: move to core, and create only one container
|
||||
// container for clip path
|
||||
const clipContatiner = this.chartContainer |
||||
.append('g') |
||||
.attr('clip-path', `url(#${this.rectClipId})`) |
||||
.attr('class', 'metrics-container'); |
||||
|
||||
// container for panning
|
||||
this.metricContainer = clipContatiner |
||||
.append('g') |
||||
.attr('class', ' metrics-rect') |
||||
|
||||
for(let idx = 0; idx < this.series.length; ++idx) { |
||||
if(this.series[idx].visible === false) { |
||||
continue; |
||||
} |
||||
// TODO: use _.defaults same as in core
|
||||
const confidence = this.series[idx].confidence || 0; |
||||
const mode = this.series[idx].mode || Mode.STANDARD; |
||||
const target = this.series[idx].target; |
||||
const renderDots = this.series[idx].renderDots !== undefined ? this.series[idx].renderDots : false; |
||||
const renderLines = this.series[idx].renderLines !== undefined ? this.series[idx].renderLines : true; |
||||
|
||||
this._renderMetric( |
||||
this.series[idx].datapoints, |
||||
{ |
||||
color: this.getSerieColor(idx), |
||||
confidence, |
||||
target, |
||||
mode, |
||||
serieIdx: idx, |
||||
renderDots, |
||||
renderLines, |
||||
} |
||||
); |
||||
} |
||||
} |
||||
|
||||
initLineGenerator(): void { |
||||
this.lineGenerator = this.d3.line() |
||||
.x(d => this.xScale(d[1])) |
||||
.y(d => this.yScale(d[0])); |
||||
} |
||||
|
||||
public appendData(data: [number, number][]): void { |
||||
this.clearScaleCache(); |
||||
|
||||
for(let idx = 0; idx < this.series.length; ++idx) { |
||||
if(this.series[idx].visible === false) { |
||||
continue; |
||||
} |
||||
this.series[idx].datapoints.push(data[idx]); |
||||
const maxLength = this.series[idx].maxLength; |
||||
if(maxLength !== undefined && this.series[idx].datapoints.length > maxLength) { |
||||
this.series[idx].datapoints.shift(); |
||||
} |
||||
} |
||||
|
||||
for(let idx = 0; idx < this.series.length; ++idx) { |
||||
this.metricContainer.select(`.metric-path-${idx}`) |
||||
.datum(this.series[idx].datapoints) |
||||
.attr('d', this.lineGenerator); |
||||
|
||||
if(this.series[idx].renderDots === true) { |
||||
this.metricContainer.selectAll(`.metric-circle-${idx}`) |
||||
.data(this.series[idx].datapoints) |
||||
.attr('cx', d => this.xScale(d[1])) |
||||
.attr('cy', d => this.yScale(d[0])); |
||||
|
||||
this._renderDots([data[idx]], idx); |
||||
} |
||||
} |
||||
|
||||
this.renderXAxis(); |
||||
this.renderYAxis(); |
||||
this.renderGrid(); |
||||
} |
||||
|
||||
_renderDots(datapoints: number[][], serieIdx: number): void { |
||||
this.metricContainer.selectAll(null) |
||||
.data(datapoints) |
||||
.enter() |
||||
.append('circle') |
||||
.attr('class', `metric-circle-${serieIdx} metric-el`) |
||||
.attr('fill', this.getSerieColor(serieIdx)) |
||||
.attr('r', METRIC_CIRCLE_RADIUS) |
||||
.style('pointer-events', 'none') |
||||
.attr('cx', d => this.xScale(d[1])) |
||||
.attr('cy', d => this.yScale(d[0])); |
||||
} |
||||
|
||||
_renderLines(datapoints: number[][], serieIdx: number): void { |
||||
this.metricContainer |
||||
.append('path') |
||||
.datum(datapoints) |
||||
.attr('class', `metric-path-${serieIdx} metric-el`) |
||||
.attr('fill', 'none') |
||||
.attr('stroke', this.getSerieColor(serieIdx)) |
||||
.attr('stroke-width', 1) |
||||
.attr('stroke-opacity', 0.7) |
||||
.attr('pointer-events', 'none') |
||||
.attr('d', this.lineGenerator); |
||||
} |
||||
|
||||
_renderMetric( |
||||
datapoints: number[][], |
||||
metricOptions: { |
||||
color: string, |
||||
confidence: number, |
||||
target: string, |
||||
mode: Mode, |
||||
serieIdx: number, |
||||
renderDots: boolean, |
||||
renderLines: boolean, |
||||
} |
||||
): void { |
||||
if(_.includes(this.seriesTargetsWithBounds, metricOptions.target)) { |
||||
return; |
||||
} |
||||
|
||||
if(metricOptions.mode === Mode.CHARGE) { |
||||
const dataPairs = this.d3.pairs(datapoints); |
||||
this.metricContainer.selectAll(null) |
||||
.data(dataPairs) |
||||
.enter() |
||||
.append('line') |
||||
.attr('x1', d => this.xScale(d[0][1])) |
||||
.attr('x2', d => this.xScale(d[1][1])) |
||||
.attr('y1', d => this.yScale(d[0][0])) |
||||
.attr('y2', d => this.yScale(d[1][0])) |
||||
.attr('stroke-opacity', 0.7) |
||||
.style('stroke-width', 1) |
||||
.style('stroke', d => { |
||||
if(d[1][0] > d[0][0]) { |
||||
return 'green'; |
||||
} else if (d[1][0] < d[0][0]) { |
||||
return 'red'; |
||||
} else { |
||||
return 'gray'; |
||||
} |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
if(metricOptions.renderLines === true) { |
||||
this._renderLines(datapoints, metricOptions.serieIdx); |
||||
} |
||||
|
||||
if(metricOptions.renderDots === true) { |
||||
this._renderDots(datapoints, metricOptions.serieIdx); |
||||
} |
||||
|
||||
let upperBoundDatapoints = []; |
||||
let lowerBoundDatapoints = []; |
||||
if( |
||||
this.options.bounds !== undefined && |
||||
this.options.bounds.upper !== undefined && |
||||
this.options.bounds.lower !== undefined |
||||
) { |
||||
this.series.forEach(serie => { |
||||
if(serie.target === this.formatedBound(this.options.bounds.upper, metricOptions.target)) { |
||||
upperBoundDatapoints = serie.datapoints; |
||||
} |
||||
if(serie.target === this.formatedBound(this.options.bounds.lower, metricOptions.target)) { |
||||
lowerBoundDatapoints = serie.datapoints; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if(upperBoundDatapoints.length > 0 && lowerBoundDatapoints.length > 0) { |
||||
const zip = (arr1, arr2) => arr1.map((k, i) => [k[0],k[1], arr2[i][0]]); |
||||
const data = zip(upperBoundDatapoints, lowerBoundDatapoints); |
||||
|
||||
this.metricContainer.append('path') |
||||
.datum(data) |
||||
.attr('fill', metricOptions.color) |
||||
.attr('stroke', 'none') |
||||
.attr('opacity', '0.3') |
||||
.attr('d', this.d3.area() |
||||
.x((d: number[]) => this.xScale(d[1])) |
||||
.y0((d: number[]) => this.yScale(d[0])) |
||||
.y1((d: number[]) => this.yScale(d[2])) |
||||
) |
||||
} |
||||
|
||||
if(metricOptions.confidence > 0) { |
||||
this.metricContainer.append('path') |
||||
.datum(datapoints) |
||||
.attr('fill', metricOptions.color) |
||||
.attr('stroke', 'none') |
||||
.attr('opacity', '0.3') |
||||
.attr('d', this.d3.area() |
||||
.x((d: [number, number]) => this.xScale(d[1])) |
||||
.y0((d: [number, number]) => this.yScale(d[0] + metricOptions.confidence)) |
||||
.y1((d: [number, number]) => this.yScale(d[0] - metricOptions.confidence)) |
||||
) |
||||
} |
||||
} |
||||
|
||||
updateCrosshair(): void { |
||||
// Base don't know anything about crosshair circles, It is only for line pod
|
||||
this.appendCrosshairCircles(); |
||||
} |
||||
|
||||
appendCrosshairCircles(): void { |
||||
// circle for each serie
|
||||
this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { |
||||
this.appendCrosshairCircle(serieIdx); |
||||
}); |
||||
} |
||||
|
||||
appendCrosshairCircle(serieIdx: number): void { |
||||
this.crosshair.append('circle') |
||||
.attr('class', `crosshair-circle-${serieIdx} crosshair-background`) |
||||
.attr('r', CROSSHAIR_BACKGROUND_RAIDUS) |
||||
.attr('clip-path', `url(#${this.rectClipId})`) |
||||
.attr('fill', this.getSerieColor(serieIdx)) |
||||
.style('opacity', CROSSHAIR_BACKGROUND_OPACITY) |
||||
.style('pointer-events', 'none'); |
||||
|
||||
this.crosshair |
||||
.append('circle') |
||||
.attr('class', `crosshair-circle-${serieIdx}`) |
||||
.attr('clip-path', `url(#${this.rectClipId})`) |
||||
.attr('fill', this.getSerieColor(serieIdx)) |
||||
.attr('r', CROSSHAIR_CIRCLE_RADIUS) |
||||
.style('pointer-events', 'none'); |
||||
} |
||||
|
||||
public renderSharedCrosshair(values: { x?: number, y?: number }): void { |
||||
this.onMouseOver(); // TODO: refactor to use it once
|
||||
const eventX = this.xScale(values.x); |
||||
const eventY = this.yScale(values.y); |
||||
this.moveCrosshairLine(eventX, eventY); |
||||
const datapoints = this.findAndHighlightDatapoints(values.x, values.y); |
||||
|
||||
if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.sharedCrosshairMove === undefined) { |
||||
console.log('Shared crosshair move, but there is no callback'); |
||||
return; |
||||
} |
||||
|
||||
this.options.eventsCallbacks.sharedCrosshairMove({ |
||||
datapoints: datapoints, |
||||
eventX, eventY |
||||
}); |
||||
} |
||||
|
||||
public hideSharedCrosshair(): void { |
||||
this.crosshair.style('display', 'none'); |
||||
} |
||||
|
||||
moveCrosshairLine(xPosition: number, yPosition: number): void { |
||||
switch(this.options.crosshair.orientation) { |
||||
case CrosshairOrientation.VERTICAL: |
||||
this.crosshair.select('#crosshair-line-x') |
||||
.attr('x1', xPosition) |
||||
.attr('x2', xPosition); |
||||
return; |
||||
case CrosshairOrientation.HORIZONTAL: |
||||
this.crosshair.select('#crosshair-line-y') |
||||
.attr('y1', yPosition) |
||||
.attr('y2', yPosition); |
||||
return; |
||||
case CrosshairOrientation.BOTH: |
||||
this.crosshair.select('#crosshair-line-x') |
||||
.attr('x1', xPosition) |
||||
.attr('x2', xPosition); |
||||
this.crosshair.select('#crosshair-line-y') |
||||
.attr('y1', yPosition) |
||||
.attr('y2', yPosition); |
||||
return; |
||||
default: |
||||
throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); |
||||
} |
||||
} |
||||
|
||||
moveCrosshairCircle(xPosition: number, yPosition: number, serieIdx: number): void { |
||||
this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) |
||||
.attr('cx', xPosition) |
||||
.attr('cy', yPosition) |
||||
.style('display', null); |
||||
} |
||||
|
||||
hideCrosshairCircle(serieIdx: number): void { |
||||
// hide circle for singe serie
|
||||
this.crosshair.selectAll(`.crosshair-circle-${serieIdx}`) |
||||
.style('display', 'none'); |
||||
} |
||||
|
||||
getClosestDatapoint(serie: LineTimeSerie, xValue: number, yValue: number): [number, number] { |
||||
// get closest datapoint to the "xValue"/"yValue" in the "serie"
|
||||
const datapoints = serie.datapoints; |
||||
const closestIdx = this.getClosestIndex(datapoints, xValue, yValue); |
||||
const datapoint = serie.datapoints[closestIdx]; |
||||
return datapoint; |
||||
} |
||||
|
||||
getClosestIndex(datapoints: [number, number][], xValue: number, yValue: number): number { |
||||
let columnIdx; // 0 for y value, 1 for x value
|
||||
let value; // xValue ot y Value
|
||||
switch(this.options.crosshair.orientation) { |
||||
case CrosshairOrientation.HORIZONTAL: |
||||
columnIdx = 0; |
||||
value = yValue; |
||||
break; |
||||
case CrosshairOrientation.VERTICAL: |
||||
columnIdx = 1; |
||||
value = xValue; |
||||
break; |
||||
case CrosshairOrientation.BOTH: |
||||
// TODO: maybe use voronoi
|
||||
columnIdx = 0; |
||||
value = yValue; |
||||
default: |
||||
throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); |
||||
} |
||||
// TODO: d3.bisect is not the best way. Use binary search
|
||||
const bisectIndex = this.d3.bisector((d: [number, number]) => d[columnIdx]).left; |
||||
let closestIdx = bisectIndex(datapoints, value); |
||||
// TODO: refactor corner cases
|
||||
if(closestIdx < 0) { |
||||
return 0; |
||||
} |
||||
if(closestIdx >= datapoints.length) { |
||||
return datapoints.length - 1; |
||||
} |
||||
// TODO: do we realy need it? Binary search should fix it
|
||||
if( |
||||
closestIdx > 0 && |
||||
Math.abs(value - datapoints[closestIdx - 1][columnIdx]) < |
||||
Math.abs(value - datapoints[closestIdx][columnIdx]) |
||||
) { |
||||
closestIdx -= 1; |
||||
} |
||||
return closestIdx; |
||||
} |
||||
|
||||
getValueInterval(columnIdx: number): number | undefined { |
||||
// columnIdx: 0 for y, 1 for x
|
||||
// inverval: x/y value interval between data points
|
||||
// TODO: move it to base/state instead of timeInterval
|
||||
const intervals = _.map(this.series, serie => { |
||||
if(serie.datapoints.length < 2) { |
||||
return undefined; |
||||
} |
||||
const start = _.head(serie.datapoints)[columnIdx]; |
||||
const end = _.last(serie.datapoints)[columnIdx]; |
||||
const range = Math.abs(end - start); |
||||
const interval = range / (serie.datapoints.length - 1); |
||||
return interval; |
||||
}); |
||||
return _.max(intervals); |
||||
} |
||||
|
||||
onMouseMove(): void { |
||||
const eventX = this.d3.mouse(this.chartContainer.node())[0]; |
||||
const eventY = this.d3.mouse(this.chartContainer.node())[1]; |
||||
const xValue = this.xScale.invert(eventX); // mouse x position in xScale
|
||||
const yValue = this.yScale.invert(eventY); |
||||
// TODO: isOutOfChart is a hack, use clip path correctly
|
||||
if(this.isOutOfChart() === true) { |
||||
this.crosshair.style('display', 'none'); |
||||
return; |
||||
} |
||||
this.moveCrosshairLine(eventX, eventY); |
||||
|
||||
const datapoints = this.findAndHighlightDatapoints(xValue, yValue); |
||||
|
||||
if(this.options.eventsCallbacks === undefined || this.options.eventsCallbacks.mouseMove === undefined) { |
||||
console.log('Mouse move, but there is no callback'); |
||||
return; |
||||
} |
||||
// TDOO: is shift key pressed
|
||||
// TODO: need to refactor this object
|
||||
this.options.eventsCallbacks.mouseMove({ |
||||
x: this.d3.event.pageX, |
||||
y: this.d3.event.pageY, |
||||
xVal: xValue, |
||||
yVal: yValue, |
||||
series: datapoints, |
||||
chartX: eventX, |
||||
chartWidth: this.width |
||||
}); |
||||
} |
||||
|
||||
findAndHighlightDatapoints(xValue: number, yValue: number): { value: [number, number], color: string, label: string }[] { |
||||
if(this.series === undefined || this.series.length === 0) { |
||||
return []; |
||||
} |
||||
let points = []; // datapoints in each metric that is closest to xValue/yValue position
|
||||
this.series.forEach((serie: LineTimeSerie, serieIdx: number) => { |
||||
if( |
||||
serie.visible === false || |
||||
_.includes(this.seriesTargetsWithBounds, serie.target) |
||||
) { |
||||
this.hideCrosshairCircle(serieIdx); |
||||
return; |
||||
} |
||||
const closestDatapoint = this.getClosestDatapoint(serie, xValue, yValue); |
||||
if(closestDatapoint === undefined || this.isOutOfRange(closestDatapoint, xValue, yValue, serie.useOutOfRange)) { |
||||
this.hideCrosshairCircle(serieIdx); |
||||
return; |
||||
} |
||||
|
||||
const yPosition = this.yScale(closestDatapoint[0]); |
||||
const xPosition = this.xScale(closestDatapoint[1]); |
||||
this.moveCrosshairCircle(xPosition, yPosition, serieIdx); |
||||
|
||||
points.push({ |
||||
value: closestDatapoint, |
||||
color: this.getSerieColor(serieIdx), |
||||
label: serie.alias || serie.target |
||||
}); |
||||
}); |
||||
return points; |
||||
} |
||||
|
||||
isOutOfRange(closestDatapoint: [number, number], xValue: number, yValue: number, useOutOfRange = true): boolean { |
||||
// find is mouse position more than xRange/yRange from closest point
|
||||
// TODO: refactor getValueInterval to remove this!
|
||||
if(useOutOfRange === false) { |
||||
return false; |
||||
} |
||||
let columnIdx; // 0 for y value, 1 for x value
|
||||
let value; // xValue ot y Value
|
||||
switch(this.options.crosshair.orientation) { |
||||
case CrosshairOrientation.HORIZONTAL: |
||||
columnIdx = 0; |
||||
value = yValue; |
||||
break; |
||||
case CrosshairOrientation.VERTICAL: |
||||
columnIdx = 1; |
||||
value = xValue; |
||||
break; |
||||
case CrosshairOrientation.BOTH: |
||||
// TODO: maybe use voronoi
|
||||
columnIdx = 0; |
||||
value = yValue; |
||||
default: |
||||
throw new Error(`Unknown type of crosshair orientaion: ${this.options.crosshair.orientation}`); |
||||
} |
||||
const range = Math.abs(closestDatapoint[columnIdx] - value); |
||||
const interval = this.getValueInterval(columnIdx); // interval between points
|
||||
// do not move crosshair circles, it mouse to far from closest point
|
||||
return interval === undefined || range > interval / 2; |
||||
} |
||||
|
||||
onMouseOver(): void { |
||||
this.crosshair.style('display', null); |
||||
this.crosshair.selectAll('.crosshair-circle') |
||||
.style('display', null); |
||||
} |
||||
|
||||
onMouseOut(): void { |
||||
if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.mouseOut !== undefined) { |
||||
this.options.eventsCallbacks.mouseOut(); |
||||
} |
||||
this.crosshair.style('display', 'none'); |
||||
} |
||||
} |
||||
|
||||
// it is used with Vue.component, e.g.: Vue.component('chartwerk-line-chart', VueChartwerkLineChartObject)
|
||||
export const VueChartwerkLineChartObject = { |
||||
// alternative to `template: '<div class="chartwerk-line-chart" :id="id" />'`
|
||||
render(createElement) { |
||||
return createElement( |
||||
'div', |
||||
{ |
||||
class: { 'chartwerk-line-chart': true }, |
||||
attrs: { id: this.id } |
||||
} |
||||
); |
||||
}, |
||||
mixins: [VueChartwerkPodMixin], |
||||
methods: { |
||||
render() { |
||||
if(this.pod === undefined) {
|
||||
this.pod = new ChartwerkLineChart(document.getElementById(this.id), this.series, this.options); |
||||
this.pod.render(); |
||||
} else { |
||||
this.pod.updateData(this.series, this.options); |
||||
} |
||||
}, |
||||
renderSharedCrosshair(values) { |
||||
this.pod.renderSharedCrosshair(values); |
||||
}, |
||||
hideSharedCrosshair() { |
||||
this.pod.hideSharedCrosshair(); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
export { LineTimeSerie, LineOptions, Mode, TickOrientation, TimeFormat }; |
@ -0,0 +1,16 @@
|
||||
import { TimeSerie, Options } from '@chartwerk/core'; |
||||
|
||||
type LineTimeSerieParams = { |
||||
confidence: number, |
||||
mode: Mode, |
||||
maxLength: number, |
||||
renderDots: boolean, |
||||
renderLines: boolean, // TODO: refactor same as scatter-pod
|
||||
useOutOfRange: boolean, // It's temporary hack. Need to refactor getValueInterval() method
|
||||
} |
||||
export enum Mode { |
||||
STANDARD = 'Standard', |
||||
CHARGE = 'Charge' |
||||
} |
||||
export type LineTimeSerie = TimeSerie & Partial<LineTimeSerieParams>; |
||||
export type LineOptions = Options; |
@ -0,0 +1,22 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"target": "es5", |
||||
"rootDir": "./src", |
||||
"module": "esnext", |
||||
"moduleResolution": "node", |
||||
"declaration": true, |
||||
"declarationDir": "dist", |
||||
"allowSyntheticDefaultImports": true, |
||||
"inlineSourceMap": false, |
||||
"sourceMap": true, |
||||
"noEmitOnError": false, |
||||
"emitDecoratorMetadata": false, |
||||
"experimentalDecorators": true, |
||||
"noImplicitReturns": true, |
||||
"noImplicitThis": false, |
||||
"noImplicitUseStrict": false, |
||||
"noImplicitAny": false, |
||||
"noUnusedLocals": false, |
||||
"baseUrl": "./src" |
||||
} |
||||
} |
Loading…
Reference in new issue