Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add lightweight chart lib #3279

Merged
merged 11 commits into from
May 30, 2024
49 changes: 49 additions & 0 deletions packages/web/components/chart/light-weight-charts/area-chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
AreaData,
AreaSeriesOptions,
AreaStyleOptions,
DeepPartial,
ISeriesApi,
SeriesOptionsCommon,
Time,
TimeChartOptions,
WhitespaceData,
} from "lightweight-charts";

import { ChartController, ChartControllerParams } from "./chart-controller";

export class AreaChartController<
T = TimeChartOptions,
K = Time
> extends ChartController<T, K> {
series: ISeriesApi<
"Area",
Time,
AreaData<Time> | WhitespaceData<Time>,
AreaSeriesOptions,
DeepPartial<AreaStyleOptions & SeriesOptionsCommon>
>[] = [];

constructor(params: ChartControllerParams<T, K>) {
super(params);

if (params.series && params.series.length > 0) {
for (const s of params.series) {
const series = this.api.addAreaSeries(s.options);
series.setData(s.data);

this.series.push(series);
}
}
}

override applyOptions(params: Partial<ChartControllerParams<T, K>>): void {
super.applyOptions(params);

if (params.series && params.series.length > 0) {
for (const [key, s] of params.series.entries()) {
this.series[key].setData(s.data);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import EventEmitter from "eventemitter3";
import {
createChart,
DeepPartial,
IChartApi,
MouseEventParams,
SeriesDataItemTypeMap,
SeriesOptionsMap,
Time,
TimeChartOptions,
} from "lightweight-charts";
DavideSegullo marked this conversation as resolved.
Show resolved Hide resolved

export interface Series {
type: keyof SeriesOptionsMap;
options: DeepPartial<SeriesOptionsMap[keyof SeriesOptionsMap]>;
data: SeriesDataItemTypeMap<Time>[keyof SeriesOptionsMap][];
}

export interface ChartControllerParams<T = TimeChartOptions, K = Time> {
options: DeepPartial<T>;
series?: Series[];
container: HTMLElement;
onCrosshairMove?: (param: MouseEventParams<K>) => void;
}

export type ChartControllerEvents<T = TimeChartOptions, K = Time> = {
crosshairMove: (param: MouseEventParams<K>) => void;
init: (params: ChartControllerParams<T, K>) => void;
remove: (params: ChartControllerParams<T, K>) => void;
};

export abstract class ChartController<T = TimeChartOptions, K = Time> {
protected api: IChartApi;
protected onCrosshairMove: ((param: MouseEventParams<K>) => void) | undefined;

events = new EventEmitter<ChartControllerEvents<T, K>>();

constructor(protected params: ChartControllerParams<T, K>) {
const { options, container, onCrosshairMove } = params;

this.onCrosshairMove = onCrosshairMove;

this.api = createChart(container, {
width: container?.clientWidth,
...options,
});

this.api.timeScale().fitContent();

this.api.subscribeCrosshairMove((param) => {
this.onCrosshairMove?.(param as never);
this.events.emit("crosshairMove", param as never);
});

this.events.emit("init", this.params);
}

applyOptions(params: Partial<ChartControllerParams<T, K>>) {
if (params.options) {
this.api.applyOptions(params.options);
}

if (params.onCrosshairMove) {
this.onCrosshairMove = params.onCrosshairMove;
}
}

resize() {
this.api.applyOptions({
...this.params.options,
width: this.params.container.clientWidth,
});

this.api.timeScale().fitContent();
}

remove() {
this.api.unsubscribeCrosshairMove((param) => {
this.onCrosshairMove?.(param as never);
this.events.emit("crosshairMove", param as never);
});
this.api.remove();
this.events.emit("remove", this.params);
}
}
214 changes: 214 additions & 0 deletions packages/web/components/chart/light-weight-charts/chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
ColorType,
DeepPartial,
isBusinessDay,
LineStyle,
MouseEventParams,
TickMarkType,
Time,
TimeChartOptions,
} from "lightweight-charts";
import React, {
memo,
PropsWithChildren,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
DavideSegullo marked this conversation as resolved.
Show resolved Hide resolved

import { theme } from "~/tailwind.config";

import {
ChartController,
ChartControllerParams,
Series,
} from "./chart-controller";

function resizeSubscribe(callback: (this: Window, ev: UIEvent) => unknown) {
window.addEventListener("resize", callback);

return () => {
window.removeEventListener("resize", callback);
};
}

const timepointToString = (
timePoint: Time,
formatOptions: Intl.DateTimeFormatOptions,
locale?: string
) => {
let date = new Date();

if (typeof timePoint === "string") {
date = new Date(timePoint);
} else if (!isBusinessDay(timePoint)) {
date = new Date((timePoint as number) * 1000);
} else {
date = new Date(
Date.UTC(timePoint.year, timePoint.month - 1, timePoint.day)
);
}

// from given date we should use only as UTC date or timestamp
// but to format as locale date we can convert UTC date to local date
const localDateFromUtc = new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds()
);

return localDateFromUtc.toLocaleString(locale, formatOptions);
};

export const defaultOptions: DeepPartial<TimeChartOptions> = {
layout: {
fontFamily: theme.fontFamily.subtitle1.join(","),
background: {
type: ColorType.Solid,
color: theme.colors.osmoverse[850],
},
textColor: theme.colors.wosmongton[200],
fontSize: 14,
},
grid: { horzLines: { visible: false }, vertLines: { visible: false } },
rightPriceScale: { visible: false },
leftPriceScale: { visible: false },
crosshair: {
horzLine: { visible: false },
vertLine: {
labelBackgroundColor: theme.colors.osmoverse[850],
style: LineStyle.LargeDashed,
width: 2,
color: `${theme.colors.osmoverse[300]}33`,
},
},
handleScroll: false,
handleScale: false,
kineticScroll: {
touch: false,
mouse: false,
},
localization: {
timeFormatter: (timePoint: Time) => {
const formatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
};

return timepointToString(timePoint, formatOptions, "en-US");
},
},
timeScale: {
timeVisible: true,
secondsVisible: false,
lockVisibleTimeRangeOnResize: true,
allowBoldLabels: false,
borderVisible: false,
fixLeftEdge: true,
fixRightEdge: true,
tickMarkFormatter: (timePoint: Time, tickMarkType: TickMarkType) => {
const formatOptions: Intl.DateTimeFormatOptions = {};

switch (tickMarkType) {
case TickMarkType.Year:
formatOptions.year = "numeric";
break;

case TickMarkType.Month:
formatOptions.month = "short";
formatOptions.year = "numeric";
break;

case TickMarkType.DayOfMonth:
formatOptions.day = "numeric";
formatOptions.month = "short";
break;

case TickMarkType.Time:
formatOptions.hour = "numeric";
formatOptions.minute = "numeric";
break;

case TickMarkType.TimeWithSeconds:
formatOptions.hour = "numeric";
formatOptions.minute = "numeric";
formatOptions.second = "2-digit";
break;
}

return timepointToString(timePoint, formatOptions, "en-US");
},
},
autoSize: true,
};

export interface ChartProps<T = TimeChartOptions, K = Time> {
options?: DeepPartial<T>;
series?: Series[];
Controller: new (params: ChartControllerParams<T, K>) => ChartController<
T,
K
>;
onCrosshairMove?: (params: MouseEventParams<K>) => void;
}

export const Chart = memo(
<T extends TimeChartOptions, K extends Time>(
props: PropsWithChildren<ChartProps<T, K>>
) => {
const {
options = { height: undefined },
children,
series,
onCrosshairMove,
Controller,
} = props;
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const chart = useRef<ChartController<T, K>>();

useSyncExternalStore(
resizeSubscribe,
() => {
chart.current?.resize();
},
() => true
);

if (container && chart.current === undefined) {
chart.current = new Controller({
options: {
...defaultOptions,
...options,
},
series,
container,
onCrosshairMove,
});
}

useEffect(() => {
chart.current?.applyOptions({ options, series });
}, [options, series]);

useEffect(() => {
return () => {
chart.current?.remove();
chart.current = undefined;
};
}, []);

return (
<div className="relative h-full" ref={setContainer}>
{children}
</div>
);
}
);
Loading
Loading