D3.jsでtimelineを描く
from SVGでtimelineを作る
D3.jsでtimelineを描く
先行研究
D3.jsでタイムラインチャートを作る - Qiita
drag&dropもできるっぽい
双方向data binding可能
I am mitsuruog | D3.jsでタイムチャートを作ってみたので、苦労した点など振り返ってみる
codepenでサンプルが紹介されている
tipsがいくつか挙げられている
D3.jsでtimelineを描く#63dd71301280f00000005dbd70をcode readingする
変更点
✅yScaleの追加
codeはD3.js#63a8dc861280f0000099db60を参考に作った
元記事では1日だけ表示するcodoだったが、ここでは任意日のtimelineを同時に表示するように書き換える
DOMをJSXで書いて座標計算のみD3.jsにやらせる
まだやっていない
rund3で試せるよう、TypeScriptからJavaScriptに書き直す
JSDocの書き方は https://zenn.dev/qnighy/articles/56917d9bf9077b を参照
2023-02-16 13:50:00 zoomがうまく動かない
拡大はできるが、縮小ができない
on("zoom", () => {...})で登録したlistenerそのものが呼ばれていないみたい
小さめのサンプルで検証する
d3-zoom
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/D3.jsでtimelineを描く/app.js
code:app.js.disabled
// @ts-check
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import { scaleTime, scaleBand } from "https://cdn.skypack.dev/d3-scale@v4.0.2?dts";
import { select } from "https://cdn.skypack.dev/d3-selection@v3.0.0?dts";
import { schemeCategory10 } from "https://cdn.skypack.dev/d3-scale-chromatic@v3.0.0?dts";
import { lightFormat } from "https://scrapbox.io/api/code/takker/date-fns/lightFormat.ts";
import { startOfDay } from "https://scrapbox.io/api/code/takker/date-fns/startOfDay.ts";
import { addMinutes } from "https://scrapbox.io/api/code/takker/date-fns/addMinutes.ts";
import { setMinutes } from "https://scrapbox.io/api/code/takker/date-fns/setMinutes.js";
import { addDays } from "https://scrapbox.io/api/code/takker/date-fns/addDays.ts";
import { zoom } from "https://cdn.skypack.dev/d3-zoom@v3.0.0?dts";
import { D3DragEvent, drag } from "https://cdn.skypack.dev/d3-drag@v3.0.0?dts";
import { axisBottom, axisLeft } from "https://cdn.skypack.dev/d3-axis@v3.0.0?dts";
あ、date-fnsにも依存してたのか
bundleして注入するか?
code:app.js
/** @typedef {import("https://cdn.skypack.dev/d3-drag@v3.0.0?dts").D3DragEvent} D3DragEvent */
/** @typedef Schedule
* @property {number} categoryNo
* @property {Date} from
* @property {Date} to
*/
/** @typedef Timeline
* @property {Date} date
* @property {Schedule[]} schedule
*/
https://takker99.github.io/rund3?js=https://scrapbox.io/api/code/takker/D3.jsでtimelineを描く/app.js
rund3のglobal変数から必要なものをimportする
code:app.js
(async () => {
const { addDays, addMinutes, setMinutes, startOfDay, lightFormat } = await import("https://cdn.jsdelivr.net/npm/date-fns@2.29.3/+esm");
const { h, html, render, useState, useMemo, useCallback, useEffect } = htmPreact;
const { select, scaleBand, scaleTime, axisBottom, axisLeft, drag, zoom, schemeCategory10 } = d3;
描画領域の準備
code:app.js
const XAxis = ({ xScale, height, padding }) => {
const chartHeight = height - padding.top - padding.bottom;
const gX, setGX = useState(null);
useEffect(() => select(gX).call(
axisBottom(xScale)
.ticks(10, "%H:%M")
.tickSize(-chartHeight)
), gX, xScale, chartHeight);
return html<g transform=${translate(0, ${height - padding.bottom})} ref=${setGX} />
};
const YAxis = ({ yScale, padding }) => {
const gY, setG = useState(null);
useEffect(
() => select(gY).call(axisLeft(yScale)),
gY, yScale,
);
return html<g transform=${translate(${padding.left}, 0)} ref=${setG} />;
};
/** @param {Date} time
* @return {Date}
*/
const makeRoundTime = (time) => {
const roundMinutesStr = ${Math.round(time.getMinutes() / 5) * 5}.padStart(
2,
"0",
);
return setMinutes(time, roundMinutesStr);
};
const Bar = ({ schedule, xScale, yScale, timeLineDomain, height }) => {
const isDrag, setIsDrag = useState(false);
const d, setD = useState(schedule);
useEffect(() => setD(schedule), schedule);
const dragger = useCallback(
drag()
.on("start", (e) => {
e.sourceEvent.stopPropagation();
setIsDrag(true);
})
.on("end", () => {
setD(({ to, from, categoryNo }) => ({
from: makeRoundTime(from),
to: makeRoundTime(to),
categoryNo,
}));
setIsDrag(false);
}),
[]
);
const bar, setRect = useState(null);
useEffect(
() => {
if (!bar) return;
select(bar).call(
dragger.on(
"drag",
/** @param {D3DragEvent<SVGRectElement, Schedule, SVGRectElement>} e */
(e) => {
setD((d) => {
const between = Math.round(
(d.to.getTime() - d.from.getTime()) / 60000,
);
const fromTime = new Date(
xScale.invert(xScale(d.from) + e.dx),
);
const toTime = addMinutes(fromTime, between);
if (timeLineDomain0.getTime() > fromTime.getTime()) return d;
else if (timeLineDomain1.getTime() < toTime.getTime()) return d;
return { from: fromTime, to: toTime, categoryNo: d.categoryNo };
});
},
),
);
},
bar, xScale, timeLineDomain,
);
return html`<rect
ref=${setRect}
x=${xScale(d.from)}
y=${yScale(d.date)}
width=${(xScale(d.to) ?? 0) - (xScale(d.from) ?? 0)}
fill=${schemeCategory10d.categoryNo % 10}
height=${height}
/>`;
};
const Timeline = ({ datasets, width, height }) => {
const padding = { top: 10, right: 10, bottom: 10, left: 75 };
const timeLineDomain = useMemo(
() => [startOfDay(datasets0.date), addDays(startOfDay(datasets0.date), 1)],
[datasets0?.data],
);
// 関数を変数としていれる
const xScale, setXScale = useState(
() => scaleTime()
.domain(timeLineDomain)
.range(padding.left, width - padding.right)
);
const yScale = useMemo(
() => scaleBand()
.rangeRound(padding.top, height - padding.top - padding.bottom)
.padding(0.1)
.domain(datasets.map((d) => lightFormat(d.date, "MM月dd日"))),
datasets, height, padding.top, padding.bottom
);
const svgEl, setSVG = useState(null);
const zoomer = useMemo(() => zoom()
.scaleExtent(1, 20)
.translateExtent(0, 0], [width, 0)
.on("zoom", (e) => {
setXScale((xScale) => e.transform.rescaleX(xScale));
}), width);
useEffect(() => zoomer(select(svgEl)), svgEl, zoomer);
const chartHeight = height - padding.top - padding.bottom;
const barHeight = chartHeight / datasets.length;
return html`<style>
:host {
position: fixed;
bottom: 0;
left: 0;
border: 1px solid gray;
border-radius: 4px;
}
.tooltip {
position: absolute;
text-align: center;
width: auto;
height: auto;
padding: 5px;
font: 12px;
background: white;
-webkit-box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.8);
-moz-box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.8);
box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.8);
visibility: hidden;
}
.bar:hover {
fill: brown;
}
rect {
cursor: move;
mix-blend-mode: multiply;
}
.dragging {
opacity: 0.7;
}
</style>
<svg width=${width} height=${height} ref=${setSVG}>
<${XAxis} xScale=${xScale} padding=${padding} height=${height} />
<${YAxis} yScale=${yScale} padding=${padding} />
${datasets0.schedule.map((d) => html<${Bar} schedule=${d} timeLineDomain=${timeLineDomain} xScale=${xScale} yScale=${yScale} height=${barHeight} />)}
</svg>`;
};
/** @type {Timeline[]} */
const datasets = [
{
date: new Date(2020, 7, 11),
schedule: [
{
categoryNo: 0,
from: new Date(2020, 7, 11),
to: new Date(2020, 7, 11, 7, 0, 0),
},
{
categoryNo: 1,
from: new Date(2020, 7, 11, 7, 30, 0),
to: new Date(2020, 7, 11, 8, 30, 0),
},
{
categoryNo: 3,
from: new Date(2020, 7, 11, 8, 30, 0),
to: new Date(2020, 7, 11, 10, 0, 0),
},
],
},
];
render(html<${Timeline} datasets=${datasets} width=${900} height=${200} />, document.body);
})().catch((e) => console.error(e));
軸の描画
X軸の描画
時刻を表示する
Y軸の描画
日付を表示する
drag&dropでデータ編集する
rectを一つのPreact Componentにして、その中で一つずつlistenerを登録させるといいかも
ここのxScaleはuseRef()で持たせるしかなさそう
dragのevent listenerを再renderingで取り替えることができないため
外側からxScaleをもらって、useEffect(() => {xScaleRef.current = xScale;},[xScale])で更新する
on("drag", null)で消去できる
https://stackoverflow.com/questions/20269384/how-do-you-remove-a-handler-using-a-d3-js-selector
d3-selectionのJSDocにも多分書いてある
上書きしたいだけなら、単に新しいlistenerをon()で登録すればいい
code:app.ts
zooming
preactで書くには
svgとgのDOMをref callback経由でstateに保存する
zoom eventが走る度に、xScaleを更新する
gはxScaleの更新をトリガーにuseEffectで更新される
#2023-02-16 12:21:39
#2023-02-15 22:41:10
#2023-02-04 05:40:14
#2023-02-03 23:12:18
#2022-12-26 17:49:26