Public
Edited
Dec 12, 2023
Insert cell
Insert cell
chart = {
// Declare the chart dimensions and margins.
const width = 928;
const height = 500;
const marginTop = 20;
const marginRight = 30;
const marginBottom = 30;
const marginLeft = 40;

// 💡 aapl 是 Observable 一个内置的数据集(在标准库中),可以直接使用
// 相关介绍可参考 https://observablehq.com/@observablehq/sample-datasets?collection=@observablehq/getting-data-in-and-out
// Declare the x (horizontal position) scale.
const x = d3.scaleUtc(
d3.extent(aapl, (d) => d.Date),
[marginLeft, width - marginRight]
);

// Declare the y (vertical position) scale.
const y = d3.scaleLinear(
[0, d3.max(aapl, (d) => d.Close)],
[height - marginBottom, marginTop]
);

// Declare the line generator.
const line = d3
.line()
.x((d) => x(d.Date))
.y((d) => y(d.Close));

// Create the SVG container.
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.attr("height", height)
.attr(
"style",
"max-width: 100%; height: auto; height: intrinsic; font: 10px sans-serif;"
)
// 设置触摸元素时的高亮颜色(默认为黑色,这里设置为透明),但它是非标准 CSS 属性,不推荐使用
// 参考 https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color
.style("-webkit-tap-highlight-color", "transparent")
.style("overflow", "visible")
// 在 svg 元素上添加与指针相关事件的监听器,以实现交互
// 当 pointerenter 和 pointermove 事件被触发时,执行 pointermoved 函数
// 函数 pointermoved 的具体代码 👇 在下面
.on("pointerenter pointermove", pointermoved)
// 当 pointerleave 事件被触发时,执行 pointerleft 函数
// 函数 pointerleft 的具体代码 👇 在下面
.on("pointerleave", pointerleft)
// 当 touchstart 事件被触发时,阻止浏览器的默认行为
.on("touchstart", (event) => event.preventDefault());

// Add the x-axis.
svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(
d3
.axisBottom(x)
.ticks(width / 80)
.tickSizeOuter(0)
);

// Add the y-axis, remove the domain line, add grid lines and a label.
svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(d3.axisLeft(y).ticks(height / 40))
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.selectAll(".tick line")
.clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1)
)
.call((g) =>
g
.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("↑ Daily Close ($)")
);

// Append a path for the line.
svg
.append("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", line(aapl));

/**
*
* 创建 Tooltip
*
*/
// 为 Tooltip 创建一个容器
const tooltip = svg.append("g");

// 用于格式化股价的方法
function formatValue(value) {
// 使用 JS 原生方法 number.toLocalString() 将数值转换为使用金钱为单位的表达格式
// 参考 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
return value.toLocaleString("en", {
style: "currency",
currency: "USD"
});
}

// 用于格式化时间的方法
function formatDate(date) {
// 使用 JS 原生方法 date.totoLocaleString() 将 Date 时间对象转换为字符串(并采用特定的格式来表达)
// 参考 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString
return date.toLocaleString("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC"
});
}

// Add the event listeners that show or hide the tooltip.
// 使用方法 d3.bisector(accessor) 创建一个数组分割器 bisector,它会基于特定值,将(有序)的数组里的元素一分为二
// 参数 accessor 是访问函数,在调用分割器对数组进行分割时,数组的每个元素都调用该访问函数,将函数返回的值用于分割/比较时该元素的代表值
// 这里返回的值是时间(用于计算数据点所对应的横坐标值)
// bisector.center 将分割的标准设置为「临近分割」
// 关于分割器的介绍参考官方文档 https://d3js.org/d3-array/bisect#bisector
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-data-process#二元分割
const bisect = d3.bisector((d) => d.Date).center;
// 鼠标指针在 svg 元素上移动时,触发该函数
function pointermoved(event) {
// 基于将鼠标所在位置(对应的横坐标值 x),使用分割器对数组 aapl 进行「临近分割」
// 💡 返回索引值 i,如果将当前鼠标所对应的横坐标值插入到该位置(可以使用数组的 arr.splice() 方法),依然保持数组有序
// 💡 也就是所该索引值所对应的数据点是最靠近鼠标(只考虑/基于横坐标值)
// 首先使用 d3.pointer(event, target) 获取指针相对于给定元素 target 的横纵坐标值(参数 target 是可选的,它的默认值是 currentTarget,即设置了该事件监听器的 DOM 元素)
// 虽然可以使用 `event.pageX` 和 `event.pageY` 来获取鼠标定位(位于网页的绝对值)
// 但是一般使用方法 d3.pointer 将鼠标位置转换为相对于接收事件的元素的局部坐标系,便于进行后续操作
// 可以参考官方文档 https://d3js.org/d3-selection/events#pointer
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/module-api/d3-module-selection#处理事件
// 然后通过 continuous.invert(value) 向比例尺传递一个值域的值 value,反过来得到定义域的值
// 这对于交互很有用,例如根据鼠标在图表的位置,反向求出并显式对应的数据
// ⚠️ 该方法只支持值域为数值类型的比例尺,否则返回 `NaN`
const i = bisect(aapl, x.invert(d3.pointer(event)[0]));
// ❓ null 是 display 属性的无效值,所以采用默认值(实际上继承自父元素,其实是 block ❓)
// tootlip 默认是隐藏的,这里将其显示出来
tooltip.style("display", null);
// 使用 CSS 的 transform 属性将 tooltip 移动到相应的位置
// 索引值 i 是 ☝️ 前面对数组 aapl 进行「临近分割」所得到的
// 通过 aapl[i] 获取最靠近鼠标的数据点,再通过比例尺 x 或 y 进行映射得到数据点相应的横纵坐标值,作为 tooltip 容器的坐标值
// 所以 tooltip 容器会定位到离鼠标最近的数据点上
tooltip.attr(
"transform",
`translate(${x(aapl[i].Date)},${y(aapl[i].Close)})`
);

// 绘制 tooltip 的边框
const path = tooltip
.selectAll("path") // 使用 <path> 元素绘制 tooltip 的边框(创建虚拟/占位元素)
// 绑定数据,该数组只有一个元素
// 因为这里是为了在 tooltip 内添加一个 <path> 元素作为边框,所以绑定数据的具体值是无所谓的
// 在这里相当于为元素绑定一个 undefined 作为数据
.data([,])
.join("path") // 将 <path> 元素挂载到父元素(tooltip 容器)上
// 设置填充颜色,为白色
.attr("fill", "white")
// 设置描边颜色,为黑色
.attr("stroke", "black");

// 设置 tooltip 内容
const text = tooltip
.selectAll("text") // 使用 <text> 元素显示文本内容(创建虚拟/占位元素)
// 绑定数据,该数组只有一个元素
// 因为这里是为了在 tooltip 内添加一个 <text> 元素来显示内容,所以绑定数据的具体值是无所谓的
// 在这里相当于为元素绑定一个 undefined 作为数据
.data([,])
.join("text") // 将 <path> 元素挂载到父元素(tooltip 容器)上
.call((text) => text
// 在 text 选择集内(即 <text> 元素内)添加 <tspan> 元素
// 它相当于在 svg 语境下的 span 元素,用于为部分文本添加样式
.selectAll("tspan")
// 绑定数据
// 一个二元数组,所以对应生成两个 <tspan> 元素
.data([formatDate(aapl[i].Date), formatValue(aapl[i].Close)])
.join("tspan") // 将 <tspan> 元素挂载到父元素上
// 设置 <tspan> 元素的定位
.attr("x", 0) // 横坐标值都是 0,即它们在水平方向上是左对齐的
// 纵坐标值根据它们在选择集中的索引值 i 计算得出
// 第一个 <tspan> 的纵坐标值是 0,作为第一行
// 第二个 <tspan> 的纵坐标值是 1.1em(em 单位是与字体大小相同的长度)相当于在第二行
.attr("y", (_, i) => `${i * 1.1}em`)
// 设置字体粗细,根据它们在选择集中的索引值 i 来设置,相当将第二行文字加粗
.attr("font-weight", (_, i) => (i ? null : "bold"))
// 设置文本内容,使用所绑定的数据作为内容
.text((d) => d)
);

// 根据 text 和 path 选择集调整 tooltip 大小
// 函数 size 的具体代码 👇 在下面
size(text, path);
}

// 鼠标指针移出 svg 元素时,触发该函数
function pointerleft() {
// 隐藏 tooltip
tooltip.style("display", "none");
}

// 将文本使用一个 callout 提示框包裹起来,而且根据文本内容设置提示框的大小
// 该函数的第一个参数 text 是包含一个 <text> 元素的选择集
// 第二个元素 path 是包含一个 <path> 元素的选择集
function size(text, path) {
// 使用方法 selection.node() 返回选择集第一个非空的元素,这里返回的是 <text> 元素
// 然后通过 SVGGraphicsElement.getBBox() 获取到该元素的大小尺寸
// 返回值是一个对象 {x: number, y: number, width: number, height: number } 表示一个矩形
// 这个矩形是刚好可以包裹该 svg 元素的最小矩形
const { x, y, width: w, height: h } = text.node().getBBox();
console.log({x, y})
// 通过 CSS 属性 transform 调整文本的定位(以关联数据点的位置作为基准,因为 tooltip 容器已经基于数据点进行了定位),让文本落入提示框中
// 在水平方向上,向左偏移 <text> 元素宽度的一半
// 在垂直方向上,向下偏移 15px(大概一个半字符高度)与原来纵坐标值 y 的差值
// 这样就可以让文本与数据点在水平方向上居中对齐,在垂直方向上位于数据点的下方
text.attr("transform", `translate(${-w / 2},${15 - y})`);
// 绘制 tooltip 边框,设置 <path> 元素的属性 `d`(具体路径形状)
// 命令 M 是将画笔进行移动
// 画笔的起始点是以关联的数据点的位置作为基准,因为 tooltip 容器已经基于数据点进行了定位
// (M${-w / 2 - 10},5 相当于将画笔移到数据点的左侧,距离大小为文本宽度的一半并加上 10px,垂直方向移到数据点的下方,距离 10px
// 接着绘制提示框的顶部边框部分
// 命令 H 绘制水平线,H-5 从画笔所在的位置绘制一条水平线到距离数据点 -5px 的位置(即相对于向右绘制一条水平线)
// 然后使用 l 命令,采用相对坐标(基于前一个命令)在中间绘制出一个小三角凸起(构成 tooltip 的指针形状,指向数据点)
// 然后再使用命令 H 绘制顶部边框的(右边)另一半的水平线
// 命令 V 绘制垂直线,然后使用 l 命令,采用相对坐标(基于前一个命令)绘制底部边框的水平线
// 最后使用 z 命令自动绘制一条(左侧边框)垂直线,以构成一个闭合边框
// 最终绘制出的 tooltip 边框,距离文本内容 10px(可以看作是 padding)
path.attr(
"d",
`M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
);
}

return svg.node();
}
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more