From 059d96ad22fe5d80b70d83a15aecf6627ac6399d Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 1 Sep 2024 21:56:09 +0900 Subject: [PATCH] wip(more charts): added --- .DS_Store | Bin 6148 -> 6148 bytes apps/next-app/package.json | 4 +- apps/next-app/src/app/heatmap/page.tsx | 86 +++ apps/next-app/src/app/line-chart/page.tsx | 36 ++ apps/next-app/src/app/linked-map/page.tsx | 40 ++ apps/next-app/src/app/relation-chart/page.tsx | 27 + .../src/app/w3-infinity-chart/page.tsx | 56 ++ apps/next-app/src/components/gradient.tsx | 17 + apps/next-app/src/components/index.tsx | 3 + .../src/components/line-chart/index.tsx | 62 ++ .../src/components/line-chart/types.ts | 18 + .../src/components/linked-map/index.tsx | 78 +++ .../src/components/linked-map/linked-map.ts | 167 +++++ .../src/components/relation-chart/index.tsx | 126 ++++ .../src/components/relation-chart/types.ts | 20 + .../components/w3-infinity-chart/index.tsx | 80 +++ .../src/components/w3-infinity-chart/types.ts | 34 + apps/next-app/tsconfig.json | 6 +- packages/heatmap/.gitignore | 1 + packages/heatmap/package.json | 61 ++ packages/heatmap/src/heatmap.ts | 597 ++++++++++++++++++ packages/heatmap/src/index.tsx | 78 +++ packages/heatmap/src/types.ts | 101 +++ packages/heatmap/tsconfig.json | 17 + packages/heatmap/tsup.config.ts | 16 + packages/heatmap/vitest.config.ts | 9 + packages/react-windrose-chart/package.json | 2 +- pnpm-lock.yaml | 61 ++ 28 files changed, 1800 insertions(+), 3 deletions(-) create mode 100644 apps/next-app/src/app/heatmap/page.tsx create mode 100644 apps/next-app/src/app/line-chart/page.tsx create mode 100644 apps/next-app/src/app/linked-map/page.tsx create mode 100644 apps/next-app/src/app/relation-chart/page.tsx create mode 100644 apps/next-app/src/app/w3-infinity-chart/page.tsx create mode 100644 apps/next-app/src/components/gradient.tsx create mode 100644 apps/next-app/src/components/index.tsx create mode 100644 apps/next-app/src/components/line-chart/index.tsx create mode 100644 apps/next-app/src/components/line-chart/types.ts create mode 100644 apps/next-app/src/components/linked-map/index.tsx create mode 100644 apps/next-app/src/components/linked-map/linked-map.ts create mode 100644 apps/next-app/src/components/relation-chart/index.tsx create mode 100644 apps/next-app/src/components/relation-chart/types.ts create mode 100644 apps/next-app/src/components/w3-infinity-chart/index.tsx create mode 100644 apps/next-app/src/components/w3-infinity-chart/types.ts create mode 100644 packages/heatmap/.gitignore create mode 100644 packages/heatmap/package.json create mode 100644 packages/heatmap/src/heatmap.ts create mode 100644 packages/heatmap/src/index.tsx create mode 100644 packages/heatmap/src/types.ts create mode 100644 packages/heatmap/tsconfig.json create mode 100644 packages/heatmap/tsup.config.ts create mode 100644 packages/heatmap/vitest.config.ts diff --git a/.DS_Store b/.DS_Store index ba268548d0e5aa10b3caf97ef1fcda2d970459f2..07151138c07b9bb621effeb97245acfe34aceeb0 100644 GIT binary patch delta 43 zcmZoMXfc@J&nUSuU^g?P { + const now = Date.now(); + const hit: [number, number[]][] = []; + const err: [number, number[]][] = []; + + for (let i = 0; i < 120; i++) { + const time = now - i * 5000; + hit.push([ + time, + Array(120) + .fill(0) + .map(() => Math.floor(Math.random() * 100)), + ]); + err.push([ + time, + Array(120) + .fill(0) + .map(() => Math.floor(Math.random() * 10)), + ]); + } + + return { hit, err }; +}; + +const App: React.FC = () => { + const [data, setData] = useState(generateRandomData()); + + useEffect(() => { + const interval = setInterval(() => { + setData(generateRandomData()); + }, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
+

+ @eunchurn/heatmap -  + Interactive Heatmap Chart +

+
+ +
+
+
+
+ { + console.log(args); + }, + }} + /> +
+
+ {/* */} +
+
+ +
+
+ +
+
+ ); +}; + +export default App; diff --git a/apps/next-app/src/app/line-chart/page.tsx b/apps/next-app/src/app/line-chart/page.tsx new file mode 100644 index 0000000..b4c7db6 --- /dev/null +++ b/apps/next-app/src/app/line-chart/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import LineChart from '@/components/line-chart'; +import { DataPoint } from '@/components/line-chart/types'; + +const LineChartExample: React.FC = () => { + const [data, setData] = useState([]); + + useEffect(() => { + // Generate some sample data + const now = Date.now(); + const newData: DataPoint[] = Array.from({ length: 50 }, (_, i) => [ + now - (49 - i) * 60000, // One point per minute + Math.random() * 100 + ]); + setData(newData); + }, []); + + return ( +
+

Line Chart Example

+ +
+ ); +}; + +export default LineChartExample; \ No newline at end of file diff --git a/apps/next-app/src/app/linked-map/page.tsx b/apps/next-app/src/app/linked-map/page.tsx new file mode 100644 index 0000000..cf0e877 --- /dev/null +++ b/apps/next-app/src/app/linked-map/page.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Gradient, LinkedMapComponent } from "@/components"; + +const App: React.FC = () => { + return ( +
+
+

+ @eunchurn/heatmap -  + Interactive Heatmap Chart +

+
+ +
+
+
+
+ +
+
+ {/* */} +
+
+ +
+
+ +
+
+ ); +}; + +export default App; diff --git a/apps/next-app/src/app/relation-chart/page.tsx b/apps/next-app/src/app/relation-chart/page.tsx new file mode 100644 index 0000000..adf6408 --- /dev/null +++ b/apps/next-app/src/app/relation-chart/page.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { RelationChart} from '@/components'; +import { ChartData } from '@/components/relation-chart/types'; + +const sampleData: ChartData = { + nodes: [ + { id: "1", pcode: "001", pname: "Project A", url: "http://example.com/a" }, + { id: "2", pcode: "002", pname: "Project B", url: "http://example.com/b" }, + { id: "3", pcode: "003", pname: "Project C", url: "http://example.com/c" }, + ], + links: [ + { source: "1", target: "2", count: 10, time_avg: 100 }, + { source: "2", target: "3", count: 5, time_avg: 50 }, + { source: "1", target: "3", count: 8, time_avg: 80 }, + ], +}; + +const RelationChartExample: React.FC = () => { + return ( +
+

Relation Chart Example

+ +
+ ); +}; + +export default RelationChartExample; \ No newline at end of file diff --git a/apps/next-app/src/app/w3-infinity-chart/page.tsx b/apps/next-app/src/app/w3-infinity-chart/page.tsx new file mode 100644 index 0000000..d7966e0 --- /dev/null +++ b/apps/next-app/src/app/w3-infinity-chart/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import InfinityChart from '@/components/w3-infinity-chart'; +import { ChartDataPoint, ChartConfig } from '@/components/w3-infinity-chart/types'; + +const InfinityChartExample: React.FC = () => { + const [data, setData] = useState([]); + + useEffect(() => { + // Generate some sample data + const newData: ChartDataPoint[] = Array.from({ length: 50 }, (_, i) => ({ + date: new Date(2023, 0, i + 1), + total: Math.random() * 100, + value: Math.random() * 50, + x: `Day ${i + 1}` + })); + setData(newData); + }, []); + + const config: ChartConfig = { + id: 'example-chart', + SHIFT_SIZE: 10, + ZOOM_SIZE: 50, + ZOOM_DIRECTION: 'LEFT', + STACKED_KEYS: ['value'], + TIME_KEYS: 'date', + X_AXIS_KEY: 'x', + X_AXIS_ID: 'date', + LEFT_AXIS_KEY: 'total', + LEFT_AXIS_TITLE: 'Total', + RIGHT_AXIS_KEY: 'value', + RIGHT_AXIS_TITLE: 'Value', + STACKED_TOTAL_KEY: 'total', + FIXED_HEIGHT: 400 + }; + + const width = 800; + const height = 400; + const margin = { top: 20, right: 20, bottom: 30, left: 40 }; + + return ( +
+

Infinity Chart Example

+ +
+ ); +}; + +export default InfinityChartExample; \ No newline at end of file diff --git a/apps/next-app/src/components/gradient.tsx b/apps/next-app/src/components/gradient.tsx new file mode 100644 index 0000000..fe6e6ae --- /dev/null +++ b/apps/next-app/src/components/gradient.tsx @@ -0,0 +1,17 @@ +export function Gradient({ + conic, + className, + small, +}: { + small?: boolean; + conic?: boolean; + className?: string; +}): JSX.Element { + return ( + + ); +} diff --git a/apps/next-app/src/components/index.tsx b/apps/next-app/src/components/index.tsx new file mode 100644 index 0000000..39545e5 --- /dev/null +++ b/apps/next-app/src/components/index.tsx @@ -0,0 +1,3 @@ +export * from "./gradient"; +export * from "./linked-map"; +export * from "./relation-chart"; diff --git a/apps/next-app/src/components/line-chart/index.tsx b/apps/next-app/src/components/line-chart/index.tsx new file mode 100644 index 0000000..369f648 --- /dev/null +++ b/apps/next-app/src/components/line-chart/index.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { useRef, useEffect, useState } from 'react'; +import * as d3 from 'd3'; +import { LineChartProps, LineChartState, DataPoint } from './types'; + +const LineChart: React.FC = ({ data, width, height, id, chartId, timeRange, maxY }) => { + const svgRef = useRef(null); + const [state, setState] = useState({ path: null }); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + + const x = d3.scaleLinear() + .range([0, width]) + .domain(timeRange); + + const y = d3.scaleLinear() + .range([height, 0]) + .domain([0, maxY]); + + const line = d3.line() + .x(d => x(d[0])) + .y(d => y(d[1])); + + if (!state.path) { + const newPath = svg + .append('path') + .datum(data) + .attr('class', `superChart ${id}`) + .attr('fill', 'none') + .attr('stroke', 'steelblue') + .attr('stroke-linejoin', 'round') + .attr('stroke-linecap', 'round') + .attr('clip-path', `url(#clipPath-${chartId})`) + .attr('stroke-width', 1.5) + .attr('d', line); + + setState({ path: newPath }); + } else { + state.path + .datum(data) + .transition() + .duration(1000) + .attr('d', line); + } + }, [data, width, height, id, chartId, timeRange, maxY, state.path]); + + return ( + + + + + + + + ); +}; + +export default LineChart; \ No newline at end of file diff --git a/apps/next-app/src/components/line-chart/types.ts b/apps/next-app/src/components/line-chart/types.ts new file mode 100644 index 0000000..24b130c --- /dev/null +++ b/apps/next-app/src/components/line-chart/types.ts @@ -0,0 +1,18 @@ +export interface DataPoint { + [0]: number; // timestamp + [1]: number; // value +} + +export interface LineChartProps { + data: DataPoint[]; + width: number; + height: number; + id: string; + chartId: string; + timeRange: [number, number]; + maxY: number; +} + +export interface LineChartState { + path: d3.Selection | null; +} \ No newline at end of file diff --git a/apps/next-app/src/components/linked-map/index.tsx b/apps/next-app/src/components/linked-map/index.tsx new file mode 100644 index 0000000..a0a8a65 --- /dev/null +++ b/apps/next-app/src/components/linked-map/index.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import LinkedMap from "./linked-map"; + +export const LinkedMapComponent: React.FC = () => { + const [map] = useState(() => new LinkedMap()); + const [keys, setKeys] = useState([]); + const [values, setValues] = useState([]); + const [newKey, setNewKey] = useState(""); + const [newValue, setNewValue] = useState(""); + + useEffect(() => { + updateState(); + }, []); + + const updateState = () => { + setKeys(Array.from(map.keys())); + setValues(Array.from(map.values())); + }; + + const handleAdd = () => { + if (newKey && newValue) { + map.put(newKey, parseInt(newValue)); + setNewKey(""); + setNewValue(""); + updateState(); + } + }; + + const handleRemove = (key: string) => { + map.remove(key); + updateState(); + }; + + return ( +
+

LinkedMap Example

+
+ setNewKey(e.target.value)} + placeholder="Key" + className="mr-2 p-2 border rounded" + /> + setNewValue(e.target.value)} + placeholder="Value" + className="mr-2 p-2 border rounded" + /> + +
+
    + {keys.map((key, index) => ( +
  • + {key}: {values[index]} + +
  • + ))} +
+
+ ); +}; + +export default LinkedMapComponent; diff --git a/apps/next-app/src/components/linked-map/linked-map.ts b/apps/next-app/src/components/linked-map/linked-map.ts new file mode 100644 index 0000000..2e527c9 --- /dev/null +++ b/apps/next-app/src/components/linked-map/linked-map.ts @@ -0,0 +1,167 @@ +type LinkedEntry = { + key: K; + value: V; + next: LinkedEntry | null; + link_next: LinkedEntry | null; + link_prev: LinkedEntry | null; +}; + +class LinkedMap { + private table: Array | null>; + private header: LinkedEntry; + private count: number; + private loadFactor: number; + private threshold: number; + private max: number; + + constructor(initCapacity: number = 101, loadFactor: number = 0.75) { + this.table = new Array(initCapacity).fill(null); + this.header = this.createEntry({} as K, null as unknown as V, null); + this.header.link_next = this.header.link_prev = this.header; + this.count = 0; + this.loadFactor = loadFactor; + this.threshold = Math.floor(initCapacity * loadFactor); + this.max = 0; + } + + private hash(key: K): number { + const keyString = String(key); + let hash = 0; + for (let i = 0; i < keyString.length; i++) { + const char = keyString.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } + + private createEntry(key: K, value: V, next: LinkedEntry | null): LinkedEntry { + return { + key, + value, + next, + link_next: null, + link_prev: null, + }; + } + + public size(): number { + return this.count; + } + + public isEmpty(): boolean { + return this.size() === 0; + } + + public put(key: K, value: V): V | null { + const index = this.hash(key) % this.table.length; + for (let e = this.table[index]; e != null; e = e.next) { + if (e.key === key) { + const oldValue = e.value; + e.value = value; + return oldValue; + } + } + + if (this.count >= this.threshold) { + this.rehash(); + } + + const e = this.createEntry(key, value, this.table[index]); + this.table[index] = e; + this.chain(this.header.link_prev!, this.header, e); + this.count++; + return null; + } + + public get(key: K): V | null { + const index = this.hash(key) % this.table.length; + for (let e = this.table[index]; e != null; e = e.next) { + if (e.key === key) { + return e.value; + } + } + return null; + } + + public remove(key: K): V | null { + const index = this.hash(key) % this.table.length; + let prev: LinkedEntry | null = null; + for (let e = this.table[index]; e != null; prev = e, e = e.next) { + if (e.key === key) { + if (prev != null) { + prev.next = e.next; + } else { + this.table[index] = e.next; + } + this.count--; + const oldValue = e.value; + this.unchain(e); + return oldValue; + } + } + return null; + } + + private rehash(): void { + const oldTable = this.table; + const newCapacity = oldTable.length * 2 + 1; + const newTable: Array | null> = new Array(newCapacity).fill(null); + this.threshold = Math.floor(newCapacity * this.loadFactor); + this.table = newTable; + + for (let i = oldTable.length - 1; i >= 0; i--) { + for (let old = oldTable[i]; old != null;) { + const e = old; + old = old.next; + const index = this.hash(e.key) % newCapacity; + e.next = newTable[index]; + newTable[index] = e; + } + } + } + + private chain(link_prev: LinkedEntry, link_next: LinkedEntry, e: LinkedEntry): void { + e.link_prev = link_prev; + e.link_next = link_next; + link_prev.link_next = e; + link_next.link_prev = e; + } + + private unchain(e: LinkedEntry): void { + e.link_prev!.link_next = e.link_next; + e.link_next!.link_prev = e.link_prev; + e.link_prev = null; + e.link_next = null; + } + + public clear(): void { + this.table.fill(null); + this.header.link_next = this.header.link_prev = this.header; + this.count = 0; + } + + public keys(): IterableIterator { + return this.iterate('key'); + } + + public values(): IterableIterator { + return this.iterate('value'); + } + + public entries(): IterableIterator<[K, V]> { + return this.iterate('entry'); + } + + private *iterate(type: 'key' | 'value' | 'entry'): IterableIterator { + let e = this.header.link_next; + while (e !== this.header && e !== null) { + if (type === 'key') yield e.key; + else if (type === 'value') yield e.value; + else yield [e.key, e.value]; + e = e.link_next; + } + } +} + +export default LinkedMap; \ No newline at end of file diff --git a/apps/next-app/src/components/relation-chart/index.tsx b/apps/next-app/src/components/relation-chart/index.tsx new file mode 100644 index 0000000..e10dce4 --- /dev/null +++ b/apps/next-app/src/components/relation-chart/index.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useRef, useEffect, useState } from 'react'; +import * as d3 from 'd3'; +import { ChartData, Node, Link } from './types'; + +const COLORS = [ + '#329af0', '#845ef7', '#00b7d0', '#00e700', '#72c3fc', + '#ffb500', '#b5bdc4', '#0ee5b1', '#ff8400', '#06c606', + '#bc83ff', '#748ffc', +]; + +interface RelationChartProps { + data: ChartData; + width: number; + height: number; +} + +export const RelationChart: React.FC = ({ data, width, height }) => { + const svgRef = useRef(null); + const [tooltip, setTooltip] = useState<{ show: boolean; content: string; x: number; y: number }>({ + show: false, + content: '', + x: 0, + y: 0, + }); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); // Clear previous content + + const simulation = d3.forceSimulation(data.nodes) + .force("link", d3.forceLink(data.links).id(d => d.id).distance(300)) + .force("charge", d3.forceManyBody().strength(-120)) + .force("center", d3.forceCenter(width / 2, height / 2)); + + const link = svg.append("g") + .selectAll("line") + .data(data.links) + .enter().append("line") + .attr("stroke", "#d3d3d3") + .attr("stroke-width", 1); + + const node = svg.append("g") + .selectAll("g") + .data(data.nodes) + .enter().append("g") + .call(d3.drag() + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended)); + + node.append("circle") + .attr("r", 5) + .attr("fill", d => COLORS[parseInt(d.pcode) % COLORS.length]); + + node.append("text") + .attr("dx", 12) + .attr("dy", ".35em") + .text(d => d.pname || d.pcode); + + node.on("mouseover", (event, d) => { + setTooltip({ + show: true, + content: `ID: ${d.id}
Project: ${d.pname || d.pcode}
URL: ${d.url}`, + x: event.pageX, + y: event.pageY, + }); + }) + .on("mouseout", () => { + setTooltip(prev => ({ ...prev, show: false })); + }); + + simulation.on("tick", () => { + link + .attr("x1", d => (d.source as unknown as Node).x!) + .attr("y1", d => (d.source as unknown as Node).y!) + .attr("x2", d => (d.target as unknown as Node).x!) + .attr("y2", d => (d.target as unknown as Node).y!); + + node + .attr("transform", d => `translate(${d.x},${d.y})`); + }); + + function dragstarted(event: d3.D3DragEvent) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + event.subject.fx = event.subject.x; + event.subject.fy = event.subject.y; + } + + function dragged(event: d3.D3DragEvent) { + event.subject.fx = event.x; + event.subject.fy = event.y; + } + + function dragended(event: d3.D3DragEvent) { + if (!event.active) simulation.alphaTarget(0); + event.subject.fx = null; + event.subject.fy = null; + } + }, [data, width, height]); + + return ( +
+ + {tooltip.show && ( +
+ )} +
+ ); +}; + +export default RelationChart; \ No newline at end of file diff --git a/apps/next-app/src/components/relation-chart/types.ts b/apps/next-app/src/components/relation-chart/types.ts new file mode 100644 index 0000000..88ec652 --- /dev/null +++ b/apps/next-app/src/components/relation-chart/types.ts @@ -0,0 +1,20 @@ +import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; + +export interface Node extends SimulationNodeDatum { + id: string; + pcode: string; + pname?: string; + url: string; +} + +export interface Link extends SimulationLinkDatum { + source: string; + target: string; + count: number; + time_avg: number; +} + +export interface ChartData { + nodes: Node[]; + links: Link[]; +} \ No newline at end of file diff --git a/apps/next-app/src/components/w3-infinity-chart/index.tsx b/apps/next-app/src/components/w3-infinity-chart/index.tsx new file mode 100644 index 0000000..d68907c --- /dev/null +++ b/apps/next-app/src/components/w3-infinity-chart/index.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React, { useRef, useEffect, useState } from 'react'; +import * as d3 from 'd3'; +import { ChartProps, ChartState, ChartDataPoint } from './types'; + +const InfinityChart: React.FC = ({ width, height, margin, data, config }) => { + const svgRef = useRef(null); + const [state, setState] = useState({ + focusedIndex: [0, data.length - 1] + }); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const x = d3.scalePoint() + .domain(data.map(d => d[config.X_AXIS_KEY] as string)) + .range([0, width - margin.left - margin.right]); + + const y = d3.scaleLinear() + .domain([0, d3.max(data, d => d[config.LEFT_AXIS_KEY] as number) || 0]) + .nice() + .range([height - margin.top - margin.bottom, 0]); + + const g = svg.append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Draw X axis + g.append("g") + .attr("class", "x-axis") + .attr("transform", `translate(0,${height - margin.top - margin.bottom})`) + .call(d3.axisBottom(x)); + + // Draw Y axis + g.append("g") + .attr("class", "y-axis") + .call(d3.axisLeft(y)); + + // Draw bars + const barWidth = Math.min(20, (width - margin.left - margin.right) / data.length - 1); + const bars = g.selectAll(".bar") + .data(data) + .enter().append("rect") + .attr("class", "bar") + .attr("x", d => (x(d[config.X_AXIS_KEY] as string) || 0) - barWidth / 2) + .attr("width", barWidth) + .attr("y", d => y(d[config.LEFT_AXIS_KEY] as number)) + .attr("height", d => height - margin.top - margin.bottom - y(d[config.LEFT_AXIS_KEY] as number)) + .attr("fill", "steelblue"); + + // Add zoom behavior + const zoom = d3.zoom() + .scaleExtent([1, 10]) + .extent([[0, 0], [width, height]]) + .on("zoom", zoomed); + + svg.call(zoom); + + function zoomed(event: d3.D3ZoomEvent) { + // @ts-ignore + const newX = event.transform.rescaleX(x); + g.selectAll(".bar") + // @ts-ignore + .attr("x", d => (newX(d[config.X_AXIS_KEY] as string) || 0) - barWidth / 2) + .attr("width", barWidth); + // @ts-ignore + g.select(".x-axis").call(d3.axisBottom(newX)); + } + + }, [data, width, height, margin, config]); + + return ( + + ); +}; + +export default InfinityChart; \ No newline at end of file diff --git a/apps/next-app/src/components/w3-infinity-chart/types.ts b/apps/next-app/src/components/w3-infinity-chart/types.ts new file mode 100644 index 0000000..ddd0462 --- /dev/null +++ b/apps/next-app/src/components/w3-infinity-chart/types.ts @@ -0,0 +1,34 @@ +export interface ChartDataPoint { + [key: string]: number | string | Date; + date: Date; + total: number; +} + +export interface ChartConfig { + id: string; + SHIFT_SIZE: number; + ZOOM_SIZE: number; + ZOOM_DIRECTION: 'LEFT' | 'RIGHT' | 'CENTER'; + STACKED_KEYS: string[]; + TIME_KEYS: string; + X_AXIS_KEY: string; + X_AXIS_ID: string; + LEFT_AXIS_KEY: string; + LEFT_AXIS_TITLE?: string; + RIGHT_AXIS_KEY?: string; + RIGHT_AXIS_TITLE?: string; + STACKED_TOTAL_KEY: string; + FIXED_HEIGHT: number; +} + +export interface ChartProps { + width: number; + height: number; + margin: { top: number; right: number; bottom: number; left: number }; + data: ChartDataPoint[]; + config: ChartConfig; +} + +export interface ChartState { + focusedIndex: [number, number]; +} \ No newline at end of file diff --git a/apps/next-app/tsconfig.json b/apps/next-app/tsconfig.json index 5c6a0f8..404a044 100644 --- a/apps/next-app/tsconfig.json +++ b/apps/next-app/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "@eunchurn/typescript-config/nextjs.json", "compilerOptions": { - "plugins": [{ "name": "next" }] + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] diff --git a/packages/heatmap/.gitignore b/packages/heatmap/.gitignore new file mode 100644 index 0000000..7951405 --- /dev/null +++ b/packages/heatmap/.gitignore @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/packages/heatmap/package.json b/packages/heatmap/package.json new file mode 100644 index 0000000..8d4fabe --- /dev/null +++ b/packages/heatmap/package.json @@ -0,0 +1,61 @@ +{ + "name": "@eunchurn/heatmap", + "version": "0.0.1", + "private": false, + "exports": { + ".": { + "import": "./lib/index.mjs", + "require": "./lib/index.js", + "types": "./lib/index.d.ts" + }, + "./*": { + "import": "./lib/*/index.mjs", + "require": "./lib/*/index.js", + "types": "./lib/*/index.d.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "lib/*" + ], + "author": "Eunchurn Park ", + "repository": { + "type": "git", + "url": "https://github.com/eunchurn/components.git", + "directory": "packages/heatmap" + }, + "license": "MIT", + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.24.7", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.0.0", + "@types/node": "22.5.1", + "@types/react": "18.3.5", + "@types/react-dom": "18.3.0", + "@types/react-is": "18.3.0", + "jest-environment-jsdom": "29.7.0", + "react-is": "18.3.1", + "ts-jest": "29.2.5", + "ts-node": "10.9.2", + "tslib": "2.7.0", + "tsup": "^8.2.4", + "typescript": "5.5.4", + "vitest": "^2.0.5" + } +} diff --git a/packages/heatmap/src/heatmap.ts b/packages/heatmap/src/heatmap.ts new file mode 100644 index 0000000..1e12ad5 --- /dev/null +++ b/packages/heatmap/src/heatmap.ts @@ -0,0 +1,597 @@ +import { colors } from "./types"; +import { HitmapChartConfig, DragCallback } from "./types"; + +interface HitmapDataPoint { + hit: number[]; + err: number[]; +} + +class HitmapChart { + private config: HitmapChartConfig; + private cvNode: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private data: Map = new Map(); + private endTime: number = Date.now(); + private xTimeRange: number = 10 * 60 * 1000; // Default to 10 minutes + private yValueMax: number = 10000; // Default max value + private chartAttr: { x: number; y: number; w: number; h: number } = { + x: 0, + y: 0, + w: 0, + h: 0, + }; + private mouse: { + x1: number; + y1: number; + x2: number; + y2: number; + down: boolean; + drag: boolean; + } = { + x1: 0, + y1: 0, + x2: 0, + y2: 0, + down: false, + drag: false, + }; + private dragCallback: DragCallback | undefined; + private remainDrag: boolean; + private staticDraw: boolean = false; + private timer: number | null = null; + private moveListener: (e: MouseEvent) => void; + private upListener: (e: MouseEvent) => void; + private theme: string; + + constructor(id: string, config: HitmapChartConfig) { + this.config = { ...defaultConfig, ...config }; + const canvas = document.getElementById(id); + if (!(canvas instanceof HTMLCanvasElement)) { + throw new Error(`Element with id '${id}' is not a canvas element`); + } + this.cvNode = canvas; + const context = this.cvNode.getContext("2d"); + if (!context) { + throw new Error("Unable to get 2D context from canvas"); + } + this.ctx = context; + this.dragCallback = config.dragCallback; + this.remainDrag = config.remainDrag || false; + this.theme = config.theme || "wh"; + this.moveListener = this.mouseMove.bind(this); + this.upListener = this.mouseUp.bind(this); + this.initParams(); + this.initCanvas(); + } + + private initParams(): void { + // Initialize parameters based on config + this.yValueMax = this.config.yAxis?.maxValue ?? this.yValueMax; + this.xTimeRange = this.config.xAxis?.timeRange ?? this.xTimeRange; + this.endTime = Date.now(); // Always set to current time when initializing + } + + private initCanvas(): void { + this.sizeCanvas(); + this.initCanvasListener(); + // Add event listeners here + } + + private sizeCanvas(): void { + const { width, height } = this.config; + const ratio = window.devicePixelRatio || 1; + this.cvNode.width = (width || this.cvNode.clientWidth) * ratio; + this.cvNode.height = (height || this.cvNode.clientHeight) * ratio; + this.cvNode.style.width = `${width || this.cvNode.clientWidth}px`; + this.cvNode.style.height = `${height || this.cvNode.clientHeight}px`; + this.ctx.scale(ratio, ratio); + } + + public loadData(dataset: { + hit: [number, number[]][]; + err: [number, number[]][]; + }): void { + this.data.clear(); + let latestTime = 0; + + (["hit", "err"] as const).forEach((type) => { + dataset[type].forEach(([time, values]) => { + const existingData = this.data.get(time) || { + hit: Array(120).fill(0), + err: Array(120).fill(0), + }; + existingData[type] = values; + this.data.set(time, existingData); + + // 가장 최근 시간 업데이트 + if (time > latestTime) { + latestTime = time; + } + }); + }); + + // endTime 업데이트 + if (latestTime > 0) { + this.endTime = latestTime; + } else { + this.endTime = Date.now(); + } + + this.draw(); + } + + private draw(): void { + // Clear canvas + this.ctx.clearRect(0, 0, this.cvNode.width, this.cvNode.height); + + // Calculate chart dimensions + this.calculateChartDimensions(); + + // Draw axes + this.drawAxes(); + + // Draw heatmap blocks + this.drawBlocks(); + this.drawMouseEvent(); + // if (this.staticDraw) { + // this.drawStaticMouseEvent(); + // } + } + + private mergeDataset(ori: number[], mer: number[], cnt: number): number[] { + const oriClone = ori.slice(0); + if (Array.isArray(mer)) { + mer.forEach((m, idx) => { + oriClone[idx] = Math.max(oriClone[idx], m); + }); + } + return oriClone; + } + public updateData(dataset: { + hit: [number, number[]][]; + err: [number, number[]][]; + }): void { + let latestTime = 0; + + (["hit", "err"] as const).forEach((type) => { + dataset[type].forEach(([time, values]) => { + const existingData = this.data.get(time) || { + hit: Array(120).fill(0), + err: Array(120).fill(0), + }; + existingData[type] = this.mergeDataset(existingData[type], values, 120); + this.data.set(time, existingData); + + if (time > latestTime) { + latestTime = time; + } + }); + }); + + if (latestTime > 0) { + this.endTime = latestTime; + } + + this.draw(); + } + private calculateChartDimensions(): void { + const { width, height } = this.cvNode; + this.chartAttr = { + x: 40, + y: 20, + w: width - 60, + h: height - 40, + }; + } + + private drawAxes(): void { + // Draw X and Y axes + this.ctx.beginPath(); + this.ctx.moveTo(this.chartAttr.x, this.chartAttr.y); + this.ctx.lineTo(this.chartAttr.x, this.chartAttr.y + this.chartAttr.h); + this.ctx.lineTo( + this.chartAttr.x + this.chartAttr.w, + this.chartAttr.y + this.chartAttr.h + ); + this.ctx.stroke(); + + // Draw Y-axis labels + for (let i = 0; i <= 5; i++) { + const y = + this.chartAttr.y + this.chartAttr.h - (i / 5) * this.chartAttr.h; + this.ctx.fillText(String((this.yValueMax * i) / 5), 5, y); + } + + // Draw X-axis labels + const timeFormat = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "numeric", + }); + for (let i = 0; i <= 5; i++) { + const x = this.chartAttr.x + (i / 5) * this.chartAttr.w; + const time = new Date(this.endTime - (1 - i / 5) * this.xTimeRange); + this.ctx.fillText( + timeFormat.format(time), + x, + this.chartAttr.y + this.chartAttr.h + 15 + ); + } + } + public changeYAxis(direction: "up" | "down"): void { + if (direction === "up") { + if (this.yValueMax < 80000) { + this.yValueMax *= 2; + this.draw(); + } + } else if (direction === "down") { + if (this.yValueMax > 5000) { + this.yValueMax /= 2; + this.draw(); + } + } + + if (this.config.cookieId) { + // Implement cookie setting logic here + } + } + + public changeTheme(theme: string): void { + this.theme = theme; + this.draw(); + } + private drawBlocks(): void { + const blockWidth = + this.chartAttr.w / + (this.xTimeRange / (this.config.xAxis?.interval || 5000)); + const blockHeight = this.chartAttr.h / 40; + + this.data.forEach((value, time) => { + const x = + this.chartAttr.x + + ((time - (this.endTime - this.xTimeRange)) / this.xTimeRange) * + this.chartAttr.w; + + (["hit", "err"] as const).forEach((type) => { + value[type].forEach((count, i) => { + if (count > 0) { + const y = + this.chartAttr.y + this.chartAttr.h - (i + 1) * blockHeight; + this.ctx.fillStyle = this.getColor(type, count); + this.ctx.fillRect(x, y, blockWidth, blockHeight); + } + }); + }); + }); + } + private drawNemo( + color: string, + x: number, + y: number, + w: number, + h: number + ): void { + this.ctx.fillStyle = color; + let widthThickCut = 0.5; + let heightThickCut = 0.5; + + if (w <= 3) widthThickCut = -2.0; + else if (w <= 5) widthThickCut = -0.8; + else if (w < 10) widthThickCut = 0.1; + + if (h <= 3) heightThickCut = -0.4; + else if (h <= 5) heightThickCut = -0.1; + else if (h < 10) heightThickCut = 0.1; + + if (w < 2) w = 2; + if (h < 2) h = 2; + + this.ctx.fillRect(x, y, w - widthThickCut, h - heightThickCut); + } + + private getTimeFormat(unit: number): (time: Date) => string { + if (unit <= 60 * 60 * 1000) { + return (time: Date) => { + const hh = this.pad(time.getHours(), 2); + const mm = this.pad(time.getMinutes(), 2); + return `${hh}:${mm}`; + }; + } + if (unit <= 24 * 60 * 60 * 1000) { + return (time: Date) => { + const hh = this.pad(time.getHours(), 2); + const mm = this.pad(time.getMinutes(), 2); + return `${hh}:${mm}`; + }; + } + return (time: Date) => { + const yyyy = time.getFullYear().toString(); + const MM = this.pad(time.getMonth() + 1, 2); + const dd = this.pad(time.getDate(), 2); + return `${yyyy}-${MM}-${dd}`; + }; + } + + private pad(number: number, length: number): string { + let str = "" + number; + while (str.length < length) { + str = "0" + str; + } + return str; + } + + private mGetIndex(time: number, max: number): number { + if (time >= max) { + return 39; + } + switch (max) { + case 5000: + return (time / 125) | 0; + case 10000: + return (time / 250) | 0; + case 20000: + return (time / 500) | 0; + case 40000: + return (time / 1000) | 0; + case 80000: + return (time / 2000) | 0; + } + return 39; + } + + private mGetTime(idx: number, max: number): number { + if (idx >= 39) { + return max; + } + switch (max) { + case 5000: + return idx * 125; + case 10000: + return idx * 250; + case 20000: + return idx * 500; + case 40000: + return idx * 1000; + case 80000: + return idx * 2000; + } + return max; + } + + private mGetTimeStep(max: number): number { + switch (max) { + case 5000: + return 125; + case 10000: + return 250; + case 20000: + return 500; + case 40000: + return 1000; + case 80000: + return 2000; + } + return 2000; + } + private getColor(type: "hit" | "err", count: number): string { + const levels = this.config.countLevel?.[type] || [0, 50, 100]; + const colors = this.config.color?.[type] || [ + "#2196f3", + "#1565c0", + "#1a237e", + ]; + + for (let i = levels.length - 1; i >= 0; i--) { + if (count >= levels[i]) return colors[i]; + } + return colors[0]; + } + + private handleColor(type: string): string { + const prefix = "COLOR_"; + const suffix = "_" + this.theme.toUpperCase(); + return colors[prefix + type.toUpperCase() + suffix]; + } + private select(p: { x1: number; y1: number; x2: number; y2: number }): void { + if (Math.abs(p.x2 - p.x1) < 3 || Math.abs(p.y2 - p.y1) < 3) return; + + p = this.mousePosAdjust(p); + const etime = this.endTime; + const stime = etime - this.xTimeRange; + const yValueMax = this.yValueMax; + const c = this.chartAttr; + + let xTime1 = this.range( + stime + (this.xTimeRange * (p.x1 - c.x)) / c.w, + stime, + etime + ); + let xTime2 = this.range( + stime + (this.xTimeRange * (p.x2 - c.x)) / c.w, + stime, + etime + ); + let yVal1 = + this.range(yValueMax * ((c.h + c.y - p.y1) / c.h), 0, yValueMax) | 0; + let yVal2 = + this.range(yValueMax * ((c.h + c.y - p.y2) / c.h), 0, yValueMax) | 0; + + [xTime1, xTime2] = this.order(xTime1, xTime2); + [yVal1, yVal2] = this.order(yVal1, yVal2); + + let hit = 0; + let err = 0; + + this.data.forEach((value, time) => { + if (time < xTime1 || xTime2 < time) return; + + const hit40 = this.conv40(value.hit, this.yValueMax); + const err40 = this.conv40(value.err, this.yValueMax); + + for (let i = 0; i < 40; i++) { + if (hit40[i] === 0 && err40[i] === 0) continue; + const v1 = (yValueMax * i) / 40; + if (yVal1 <= v1 && v1 <= yVal2) { + hit += hit40[i]; + err += err40[i]; + } + } + }); + + const xValue = [xTime1, xTime2]; + const yValue = [yVal1, yVal2]; + + if (this.dragCallback && (hit !== 0 || err !== 0)) { + this.dragCallback(xValue, yValue, this.yValueMax); + } + } + + private mousePosAdjust(pos: { + x1: number; + y1: number; + x2: number; + y2: number; + }): { x1: number; y1: number; x2: number; y2: number } { + if (pos.x1 > pos.x2) { + [pos.x1, pos.x2] = [pos.x2, pos.x1]; + } + if (pos.y1 > pos.y2) { + [pos.y1, pos.y2] = [pos.y2, pos.y1]; + } + return pos; + } + + private mouseMove(e: MouseEvent): void { + if (this.mouse.drag === true) { + const clientRect = this.cvNode.getBoundingClientRect(); + this.mouse.x2 = e.clientX - clientRect.left; + this.mouse.y2 = e.clientY - clientRect.top; + this.draw(); + } + } + + private mouseUp(e: MouseEvent): void { + this.mouse.down = false; + this.mouse.drag = false; + this.select(this.mouse); + + if (!this.remainDrag) { + this.draw(); + } else if ( + Math.abs(this.mouse.x2 - this.mouse.x1) < 3 || + Math.abs(this.mouse.y2 - this.mouse.y1) < 3 + ) { + this.draw(); + } + + this.mouse.x1 = 0; + this.mouse.y1 = 0; + document.removeEventListener("mousemove", this.moveListener, true); + document.removeEventListener("mouseup", this.upListener, true); + } + + private initCanvasListener(): void { + this.cvNode.onmousedown = (e: MouseEvent) => { + this.staticDraw = false; + + if (!this.mouse.drag) { + const rect = (e.target as HTMLElement).getBoundingClientRect(); + this.mouse.x1 = this.mouse.x2 = e.clientX - rect.left; + this.mouse.y1 = this.mouse.y2 = e.clientY - rect.top; + this.moveListener = this.mouseMove.bind(this); + this.upListener = this.mouseUp.bind(this); + document.addEventListener("mousemove", this.moveListener, true); + document.addEventListener("mouseup", this.upListener, true); + } + + this.mouse.down = true; + this.mouse.drag = true; + }; + + this.cvNode.onmouseover = (e: MouseEvent) => { + this.cvNode.style.cursor = "crosshair"; + if (this.mouse.drag) { + this.mouse.down = true; + } + if (this.timer) { + clearTimeout(this.timer); + } + }; + } + + private drawMouseEvent(): void { + if (this.mouse && this.mouse.drag) { + const p = this.mouse; + const width_start = this.chartAttr.x - 5; + const height_start = this.chartAttr.y - 5; + const width_end = this.chartAttr.x + this.chartAttr.w + 5; + const height_end = this.chartAttr.y + this.chartAttr.h + 5; + + p.x1 = this.range(p.x1, width_start, width_end); + p.x2 = this.range(p.x2, width_start, width_end); + p.y1 = this.range(p.y1, height_start, height_end); + p.y2 = this.range(p.y2, height_start, height_end); + + this.ctx.setLineDash([5, 1]); + this.ctx.lineWidth = 2; + this.ctx.strokeStyle = this.handleColor("range"); + this.ctx.strokeRect(p.x1, p.y1, p.x2 - p.x1, p.y2 - p.y1); + } + } + + // Helper methods that were previously in hitmapApi + private range(v: number, min: number, max: number): number { + if (v < min) return min; + if (v > max) return max; + return v; + } + + private order(v1: number, v2: number): [number, number] { + return v1 <= v2 ? [v1, v2] : [v2, v1]; + } + + private conv40(hit: number[], max: number): number[] { + const h2 = new Array(40).fill(0); + + switch (max) { + case 5000: + for (let i = 0; i < 40; i++) { + h2[i] += hit[i] || 0; + } + for (let i = 40; i < 120; i++) { + h2[39] += hit[i] || 0; + } + break; + // ... other cases ... + default: + for (let i = 0; i < 40; i++) { + h2[i] = hit[i]; + } + } + + return h2; + } +} + +const defaultConfig: HitmapChartConfig = { + xAxis: { + timeRange: 10 * 60 * 1000, + interval: 5000, + }, + yAxis: { + maxValue: 10000, + tickFormat: (d: number) => d / 1000, + plots: 6, + hasLine: true, + }, + countLevel: { + hit: [0, 150, 300], + err: [0, 3, 6], + }, + color: { + hit: ["#2196f3", "#1565c0", "#1a237e"], + err: ["#f9a825", "#ef6c00", "#d50000"], + }, + theme: "light", +}; + +export { HitmapChart, type HitmapChartConfig }; diff --git a/packages/heatmap/src/index.tsx b/packages/heatmap/src/index.tsx new file mode 100644 index 0000000..70d786e --- /dev/null +++ b/packages/heatmap/src/index.tsx @@ -0,0 +1,78 @@ +import React, { useRef, useEffect, useState } from "react"; +import { HitmapChart, HitmapChartConfig } from "./heatmap"; +export * from "./heatmap"; + +export interface HeatmapData { + hit: [number, number[]][]; + err: [number, number[]][]; +} + +interface HeatmapChartProps { + width: number; + height: number; + data: HeatmapData; + config?: HitmapChartConfig; + onChangeYAxis?: (direction: "up" | "down") => void; + onChangeTheme?: (theme: string) => void; +} + +export const HeatmapChart: React.FC = ({ + width, + height, + data, + config, + onChangeYAxis, + onChangeTheme, +}) => { + const canvasRef = useRef(null); + const [chart, setChart] = useState(null); + + useEffect(() => { + if (canvasRef.current) { + const defaultConfig: HitmapChartConfig = { + width, + height, + xAxis: { + timeRange: 10 * 60 * 1000, + interval: 5000, + }, + yAxis: { + maxValue: 10000, + tickFormat: (d: number) => d / 1000, + }, + // dragCallback: (xValue, yValue, yValueMax) => { + // console.log("selected range", xValue, yValue, yValueMax); + // }, + }; + const newChart = new HitmapChart(canvasRef.current.id, { + ...defaultConfig, + ...config, + }); + setChart(newChart); + } + }, [width, height]); + + useEffect(() => { + if (chart) { + chart.loadData(data); + } + }, [chart, data]); + useEffect(() => { + if (chart && onChangeYAxis) { + onChangeYAxis = (direction: "up" | "down") => { + chart.changeYAxis(direction); + }; + } + }, [chart, onChangeYAxis]); + + useEffect(() => { + if (chart && onChangeTheme) { + onChangeTheme = (theme: string) => { + chart.changeTheme(theme); + }; + } + }, [chart, onChangeTheme]); + return ; +}; + +export default HeatmapChart; diff --git a/packages/heatmap/src/types.ts b/packages/heatmap/src/types.ts new file mode 100644 index 0000000..8e174ec --- /dev/null +++ b/packages/heatmap/src/types.ts @@ -0,0 +1,101 @@ +export type DragCallback = ( + xValue: number[], + yValue: number[], + yValueMax: number +) => void; + +export interface HitmapChartConfig { + hit?: { + minWidth?: number; + minHeight?: number; + maxHeight?: number; + maxWidth?: number; + maxYCnt?: number; + }; + view?: { + fitToWidth?: boolean; + }; + xAxis?: { + interval?: number; + timeRange?: number; + endTime?: number; + }; + yAxis?: { + tickFormat?: (d: number) => string | number; + plots?: number; + hasLine?: boolean; + maxValue?: number; + }; + dragTooltip?: boolean; + dragCallback?: DragCallback; + remainDrag?: boolean; + buttons?: boolean; + legends?: "top" | "bottom"; + isLive?: boolean; + updateMaxData?: number; + deleteOldCount?: number; + countLevel?: { + hit: number[]; + err: number[]; + }; + color?: { + hit: string[]; + err: string[]; + }; + applyGlobalSkin?: boolean; + isStatic?: boolean; + width?: number; + height?: number; + theme?: "light" | "dark"; + cookieId?: string; +} + +export interface HitmapDataPoint { + hit: number[]; + err: number[]; + add: (x: number, v: number) => void; +} + +export type MousePosition = { + x1: number; + y1: number; + x2: number; + y2: number; + down: boolean; + drag: boolean; +}; + +export interface ChartAttribute { + x: number; + y: number; + w: number; + h: number; +} + +export type TimeFormatFunction = (time: Date) => string; + +export interface DatasetUpdate { + hit: [number, number[]][]; + err: [number, number[]][]; +} + +export interface Colors { + [key: string]: string; +} + +export const colors: Colors = { + COLOR_LEGEND_WH: "#000000", + COLOR_LEGEND_BK: "#ffffff", + COLOR_GUIDE_WH: "#E0E0E0", + COLOR_GUIDE_BK: "#3f3f3f", + COLOR_RANGE_WH: "#000000", + COLOR_RANGE_BK: "#ffffff", + COLOR_BORDER_WH: "#000000", + COLOR_BORDER_BK: "#C4C4C4", + COLOR_BORDER_TOP_WH: "#E0E0E0", + COLOR_BORDER_TOP_BK: "#C4C4C4", + COLOR_BORDER_BOTTOM_WH: "#000000", + COLOR_BORDER_BOTTOM_BK: "#C4C4C4", + COLOR_GUIDE_UNIT_WH: "#E3E6EA", + COLOR_GUIDE_UNIT_BK: "#3f3f3f", +}; diff --git a/packages/heatmap/tsconfig.json b/packages/heatmap/tsconfig.json new file mode 100644 index 0000000..34b3f26 --- /dev/null +++ b/packages/heatmap/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@eunchurn/typescript-config/react-library.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./lib", + "target": "esnext", + "module": "esnext" + }, + "include": [ + "src" + ], + "exclude": [ + "lib", + "node_modules", + "**/__tests__" + ] +} \ No newline at end of file diff --git a/packages/heatmap/tsup.config.ts b/packages/heatmap/tsup.config.ts new file mode 100644 index 0000000..80fc0f8 --- /dev/null +++ b/packages/heatmap/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig((options) => ({ + entry: ["src/index.ts", "src/**/*.ts", "src/**/*.tsx", "!**/__tests__"], + format: ["cjs", "esm"], + outDir: "lib", + dts: true, + clean: true, + sourcemap: true, + cjsInterop: true, + target: "es2022", + external: ["react", "react/jsx-runtime"], + minify: !options.watch, + banner: { js: '"use client";' }, + ...options, +})); diff --git a/packages/heatmap/vitest.config.ts b/packages/heatmap/vitest.config.ts new file mode 100644 index 0000000..dab6811 --- /dev/null +++ b/packages/heatmap/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + passWithNoTests: true, + watch: false + }, +}); diff --git a/packages/react-windrose-chart/package.json b/packages/react-windrose-chart/package.json index 506c315..5d896a3 100644 --- a/packages/react-windrose-chart/package.json +++ b/packages/react-windrose-chart/package.json @@ -47,7 +47,7 @@ "windrose rollup react component" ], "scripts": { - "dev": "tsup --watch", + "dev": "vite", "build": "vite build", "test": "vitest", "typecheck": "tsc --noEmit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b894380..5c663a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@eunchurn/react-windrose': specifier: workspace:* version: link:../../packages/react-windrose + d3-force: + specifier: ^3.0.0 + version: 3.0.0 next: specifier: ^14.2.3 version: 14.2.7(@babel/core@7.25.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) @@ -45,6 +48,9 @@ importers: '@eunchurn/eslint-config': specifier: workspace:* version: link:../../packages/config-eslint + '@eunchurn/heatmap': + specifier: workspace:* + version: link:../../packages/heatmap '@eunchurn/tailwind-config': specifier: workspace:* version: link:../../packages/config-tailwind @@ -212,6 +218,61 @@ importers: packages/config-typescript: {} + packages/heatmap: + dependencies: + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@babel/preset-typescript': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.25.2) + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: 22.5.1 + version: 22.5.1 + '@types/react': + specifier: 18.3.5 + version: 18.3.5 + '@types/react-dom': + specifier: 18.3.0 + version: 18.3.0 + '@types/react-is': + specifier: 18.3.0 + version: 18.3.0 + jest-environment-jsdom: + specifier: 29.7.0 + version: 29.7.0 + react-is: + specifier: 18.3.1 + version: 18.3.1 + ts-jest: + specifier: 29.2.5 + version: 29.2.5(@babel/core@7.25.2)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(esbuild@0.23.1)(jest@29.7.0(@types/node@22.5.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.22(@swc/helpers@0.5.5))(@types/node@22.5.1)(typescript@5.5.4)))(typescript@5.5.4) + ts-node: + specifier: 10.9.2 + version: 10.9.2(@swc/core@1.7.22(@swc/helpers@0.5.5))(@types/node@22.5.1)(typescript@5.5.4) + tslib: + specifier: 2.7.0 + version: 2.7.0 + tsup: + specifier: ^8.2.4 + version: 8.2.4(@microsoft/api-extractor@7.47.4(@types/node@22.5.1))(@swc/core@1.7.22(@swc/helpers@0.5.5))(jiti@1.21.6)(postcss@8.4.41)(typescript@5.5.4)(yaml@2.5.0) + typescript: + specifier: 5.5.4 + version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@22.5.1)(jsdom@20.0.3)(less@4.2.0)(lightningcss@1.26.0)(sass@1.77.8)(stylus@0.62.0)(terser@5.31.6) + packages/react-windrose: dependencies: react: