D3.jsでtimelineを描く
先行研究
双方向data binding可能
codepenでサンプルが紹介されている
tipsがいくつか挙げられている
変更点
✅yScaleの追加
元記事では1日だけ表示するcodoだったが、ここでは任意日のtimelineを同時に表示するように書き換える
まだやっていない
rund3で試せるよう、TypeScriptからJavaScriptに書き直す 2023-02-16 13:50:00 zoomがうまく動かない
拡大はできるが、縮小ができない
on("zoom", () => {...})で登録したlistenerそのものが呼ばれていないみたい
小さめのサンプルで検証する
code:app.js.disabled
// @ts-check
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
あ、date-fnsにも依存してたのか
bundleして注入するか?
code:app.js
/** @typedef Schedule
* @property {number} categoryNo
* @property {Date} from
* @property {Date} to
*/
/** @typedef Timeline
* @property {Date} date
* @property {Schedule[]} schedule
*/
rund3のglobal変数から必要なものをimportする
code:app.js
(async () => {
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;
useEffect(() => select(gX).call(
axisBottom(xScale)
.ticks(10, "%H:%M")
.tickSize(-chartHeight)
return html<g transform=${translate(0, ${height - padding.bottom})} ref=${setGX} />
};
const YAxis = ({ yScale, padding }) => {
useEffect(
() => select(gY).call(axisLeft(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 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);
}),
[]
);
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 };
});
},
),
);
},
);
return html`<rect
ref=${setRect}
x=${xScale(d.from)}
y=${yScale(d.date)}
width=${(xScale(d.to) ?? 0) - (xScale(d.from) ?? 0)}
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)], );
// 関数を変数としていれる
() => scaleTime()
.domain(timeLineDomain)
);
const yScale = useMemo(
() => scaleBand()
.padding(0.1)
.domain(datasets.map((d) => lightFormat(d.date, "MM月dd日"))),
);
const zoomer = useMemo(() => zoom()
.translateExtent(0, 0], [width, 0)
.on("zoom", (e) => {
setXScale((xScale) => e.transform.rescaleX(xScale));
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軸の描画
日付を表示する
rectを一つのPreact Componentにして、その中で一つずつlistenerを登録させるといいかも
ここのxScaleはuseRef()で持たせるしかなさそう
dragのevent listenerを再renderingで取り替えることができないため
外側からxScaleをもらって、useEffect(() => {xScaleRef.current = xScale;},[xScale])で更新する
on("drag", null)で消去できる
上書きしたいだけなら、単に新しいlistenerをon()で登録すればいい
code:app.ts
zooming
preactで書くには
zoom eventが走る度に、xScaleを更新する
gはxScaleの更新をトリガーにuseEffectで更新される