From ea7e57f4af01da8db88384b196210fb7faedd358 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Sat, 4 Feb 2023 16:18:04 -0800 Subject: [PATCH] split ScatterPlot into data-agnostic ScatterPlot and ElementScatter --- .eslintrc.yml | 1 + src/lib/ElementPhoto.svelte | 5 +- src/lib/ElementScatter.svelte | 50 ++++++++++++++++ src/lib/ScatterPlot.svelte | 95 ++++++++++++------------------- src/lib/index.ts | 3 +- src/routes/+page.svelte | 12 ++-- src/routes/[slug]/+page.svelte | 8 +-- src/site/PropertySelect.svelte | 12 ++-- tests/periodic-table.test.ts | 2 +- tests/unit/element-tile.test.ts | 25 ++++++++ tests/unit/index.test.ts | 10 ++-- tests/unit/periodic-table.test.ts | 6 +- 12 files changed, 141 insertions(+), 88 deletions(-) create mode 100644 src/lib/ElementScatter.svelte diff --git a/.eslintrc.yml b/.eslintrc.yml index 66355d2..409b7ea 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -25,3 +25,4 @@ rules: no-var: error # allow triple slash for typescript file referencing https://git.io/JCeqO spaced-comment: [error, always, { markers: [/] }] + '@typescript-eslint/no-inferrable-types': off diff --git a/src/lib/ElementPhoto.svelte b/src/lib/ElementPhoto.svelte index 7a7784a..73848f8 100644 --- a/src/lib/ElementPhoto.svelte +++ b/src/lib/ElementPhoto.svelte @@ -2,7 +2,7 @@ import { Icon, type ChemicalElement } from '.' export let element: ChemicalElement - // style applies to img and missing_msg div + // style applies to both img and missing_msg div export let style: string | null = null export let missing_msg = `No image for ` @@ -13,7 +13,7 @@ $: src, (hidden = false) // reset hidden to false when src changes -{#if element} +{#if name && number} {name} (hidden = true)} {style} {hidden} /> {#if hidden && missing_msg}
@@ -30,6 +30,7 @@ width: 100%; object-fit: cover; margin: 0; + border-radius: 4pt; } div { aspect-ratio: 1; diff --git a/src/lib/ElementScatter.svelte b/src/lib/ElementScatter.svelte new file mode 100644 index 0000000..9c8651d --- /dev/null +++ b/src/lib/ElementScatter.svelte @@ -0,0 +1,50 @@ + + + x + 1)} + {...$$props} + bind:tooltip_point + bind:hovered + {x_label} + on:change +> +
+ {x} - {element_data[x - 1]?.name} +
{y_label} = {pretty_num(y)} + {y_unit ?? ``} +
+
+ + diff --git a/src/lib/ScatterPlot.svelte b/src/lib/ScatterPlot.svelte index 2e8e553..8f283b4 100644 --- a/src/lib/ScatterPlot.svelte +++ b/src/lib/ScatterPlot.svelte @@ -2,11 +2,9 @@ import { bisector, extent } from 'd3-array' import type { ScaleLinear } from 'd3-scale' import { scaleLinear } from 'd3-scale' - import type { ChemicalElement, PlotPoint } from '.' + import { createEventDispatcher } from 'svelte' + import type { Coords } from '.' import { Line, ScatterPoint } from '.' - import element_data from './element-data' - import { pretty_num } from './labels' - import { active_element } from './stores' export let style = `` export let x_lim: [number | null, number | null] = [null, null] @@ -15,26 +13,25 @@ export let pad_bottom = 30 export let pad_left = 50 export let pad_right = 20 - export let on_hover_point: ((point: PlotPoint) => void) | null = null - export let x_label = `Atomic Number` - export let x_label_y = 0 + export let x_label: string = `` + export let x_label_yshift = 0 + export let x: number[] = [] export let color_scale: ScaleLinear | null = null - // either array of length 118 (one heat value for each element) or object with - // element symbol as key and heat value as value - export let y_values: number[] - export let y_label: string + export let y: number[] = [] + export let y_label: string = `` export let y_unit = `` + export let tooltip_point: Coords + export let hovered = false - let data_points: PlotPoint[] - $: data_points = element_data.map((elem, idx) => [elem.number, y_values[idx], elem]) - + const dispatcher = createEventDispatcher() const axis_label_offset = { x: 15, y: 20 } // pixels - let width: number let height: number + + $: data = x.map((x, idx) => ({ x, y: y[idx] })) // determine x/y-range from data but default to x/y-lim if defined - $: x_range = extent(data_points, (point) => point[0]).map((x, idx) => x_lim[idx] ?? x) - $: y_range = extent(data_points, (point) => point[1]).map((y, idx) => y_lim[idx] ?? y) + $: x_range = extent(data, ({ x }) => x).map((x, idx) => x_lim[idx] ?? x) + $: y_range = extent(data, ({ y }) => y).map((y, idx) => y_lim[idx] ?? y) $: x_scale = scaleLinear() .domain(x_range) @@ -44,31 +41,24 @@ .domain(y_range) .range([height - pad_bottom, pad_top]) - let scaled_data: [number, number, string, ChemicalElement][] + let scaled_data: [number, number, string][] // make sure to apply colorscale to y values before scaling - $: scaled_data = data_points - .filter(([x, y]) => !(isNaN(x) || isNaN(y) || x === null || y === null)) - .map(([x, y, elem]) => [x_scale(x), y_scale(y), color_scale?.(y), elem]) + $: scaled_data = data + ?.filter(({ x, y }) => !(isNaN(x) || isNaN(y) || x === null || y === null)) + .map(({ x, y }) => [x_scale(x), y_scale(y), color_scale?.(y)]) - let tooltip_point: PlotPoint - let hovered = false - const bisect = bisector((data_point: PlotPoint) => data_point[0]).right + const bisect = bisector(({ x }) => x).right - // update tooltip on hover element tile - $: if ($active_element?.number) { - hovered = true - tooltip_point = data_points[$active_element.number - 1] - } function on_mouse_move(event: MouseEvent) { hovered = true - const mouse_coords = [event.offsetX, event.offsetY] // returns point to right of our current mouse position - let arr_idx = bisect(data_points, x_scale.invert(mouse_coords[0])) - if (arr_idx < data_points.length) { - tooltip_point = data_points[arr_idx] // update point - if (on_hover_point) on_hover_point(tooltip_point) + let idx = bisect(data, x_scale.invert(event.offsetX)) + + if (idx < data.length) { + tooltip_point = data[idx] // update point + dispatcher(`change`, tooltip_point) } } @@ -80,10 +70,7 @@ on:mouseleave={() => (hovered = false)} on:mouseleave > - [x, y])} - origin={[x_scale(x_range[0]), y_scale(y_range[0])]} - /> + {#each scaled_data as [x, y, fill]} {/each} @@ -96,7 +83,7 @@ {tick} {/each} - + {x_label ?? ``} @@ -120,19 +107,16 @@ {#if tooltip_point} - {@const [atomic_num, raw_y] = tooltip_point} - {@const [x, y] = [x_scale(atomic_num), y_scale(raw_y)]} - - {#if hovered} - -
- {atomic_num} - {tooltip_point[2].name} - {#if raw_y} -
{y_label} = {pretty_num(raw_y)} {y_unit ?? ``} - {/if} -
-
- {/if} + {@const { x, y } = tooltip_point} + {@const [cx, cy] = [x_scale(x), y_scale(y)]} + + + + + ({x}, {y}) + + + {/if} {/if} @@ -143,6 +127,7 @@ width: 100%; height: 100%; display: flex; + min-height: var(--svt-min-height, 100px); } svg { width: 100%; @@ -171,12 +156,6 @@ foreignObject { overflow: visible; } - foreignObject div { - background-color: rgba(0, 0, 0, 0.7); - padding: 1pt 3pt; - width: max-content; - box-sizing: border-box; - } text.label { text-anchor: middle; font-size: clamp(11pt, 1.2vw, 16pt); diff --git a/src/lib/index.ts b/src/lib/index.ts index 17ca08d..0fe6238 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -3,6 +3,7 @@ export { default as ColorCustomizer } from './ColorCustomizer.svelte' export { default as element_data } from './element-data' export { default as ElementHeading } from './ElementHeading.svelte' export { default as ElementPhoto } from './ElementPhoto.svelte' +export { default as ElementScatter } from './ElementScatter.svelte' export { default as ElementStats } from './ElementStats.svelte' export { default as ElementTile } from './ElementTile.svelte' export { default as Icon } from './Icon.svelte' @@ -72,7 +73,7 @@ export type ChemicalElement = { year: number | string } -export type PlotPoint = [number, number, ChemicalElement] +export type Coords = { x: number; y: number } export type DispatchPayload = CustomEvent<{ element: ChemicalElement diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0fde904..5c24734 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,10 +2,10 @@ import { BohrAtom, ColorCustomizer, + ElementScatter, ElementStats, element_data, PeriodicTable, - ScatterPlot, TableInset, } from '$lib' import { property_labels } from '$lib/labels' @@ -28,7 +28,7 @@

Periodic Table of Elements

- + {#if $last_element && window_width > 1100} {@const { shells, name, symbol } = $last_element} @@ -47,13 +47,13 @@ > {#if $heatmap_key} - el[$heatmap_key])} + y={element_data.map((el) => el[$heatmap_key])} {y_label} {y_unit} - on_hover_point={(point) => ($active_element = point[2])} - x_label_y={42} + on:change={(e) => ($active_element = element_data[e.detail.x - 1])} + x_label_yshift={42} {color_scale} /> {:else} diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte index 530b827..a44437f 100644 --- a/src/routes/[slug]/+page.svelte +++ b/src/routes/[slug]/+page.svelte @@ -4,10 +4,10 @@ BohrAtom, ElementHeading, ElementPhoto, + ElementScatter, element_data, Icon, PeriodicTable, - ScatterPlot, } from '$lib' import { pretty_num, property_labels } from '$lib/labels' import { active_element, heatmap_key } from '$lib/stores' @@ -100,9 +100,9 @@ missing_msg={window_width < 900 ? `` : `No image for`} /> - - + - diff --git a/tests/periodic-table.test.ts b/tests/periodic-table.test.ts index 2b132b3..3f9a625 100644 --- a/tests/periodic-table.test.ts +++ b/tests/periodic-table.test.ts @@ -67,7 +67,7 @@ test.describe(`Periodic Table`, () => { const heatmap_val = pretty_num(random_element[heatmap_keys.at(-1)]) - // make sure Fluorine electronegativity value is displayed correctly + // make sure heatmap value is displayed correctly const elem_tile = await page.$( `text=${rand_idx + 1} ${random_element.symbol} ${heatmap_val}`, { strict: true } diff --git a/tests/unit/element-tile.test.ts b/tests/unit/element-tile.test.ts index f68786c..8b04f72 100644 --- a/tests/unit/element-tile.test.ts +++ b/tests/unit/element-tile.test.ts @@ -121,4 +121,29 @@ describe(`ElementTile`, () => { } ) }) + // test(`show_symbol, show_name and show_number props control labels`, async () => { + // // test all 2^3 true/false combinations of show_symbol, show_name, show_number + // for (let idx = 0; idx < 1 << 3; idx++) { + // // const show_name = Boolean(idx & 1) + // const show_name = true + // // const show_number = Boolean(idx & (1 << 1)) + // const show_number = true + // const show_symbol = Boolean(idx & (1 << 2)) + // // const show_symbol = true + + // let expected = `` + // if (show_number) expected += rand_element.number + // if (show_symbol) expected += ` ${rand_element.symbol}` + // if (show_name) expected += ` ${rand_element.name}` + // new ElementTile({ + // target: document.body, + // props: { element: rand_element, show_symbol, show_name, show_number }, + // }) + + // const div = document.querySelector(`.element-tile`) + // console.log(`node.innerHTML`, div.innerHTML) + + // expect(div.textContent?.trim()).toBe(expected.trim()) + // } + // }) }) diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index c9134e5..4ff116c 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,7 +1,7 @@ import * as lib from '$lib' -import IndexPeriodicTable, { PeriodicTable, ScatterPlot } from '$lib' +import IndexPeriodicTable, { ElementScatter, PeriodicTable } from '$lib' +import FileElementScatter from '$lib/ElementScatter.svelte' import FilePeriodicTable from '$lib/PeriodicTable.svelte' -import FileScatterPlot from '$lib/ScatterPlot.svelte' import { expect, test } from 'vitest' test(`PeriodicTable is named and default export`, () => { @@ -9,10 +9,10 @@ test(`PeriodicTable is named and default export`, () => { expect(FilePeriodicTable).toBe(PeriodicTable) }) -test(`ScatterPlot is named export`, () => { - expect(FileScatterPlot).toBe(ScatterPlot) +test(`ElementScatter is named export`, () => { + expect(FileElementScatter).toBe(ElementScatter) }) -test(`lib exports PeriodicTable and ScatterPlot`, () => { +test(`lib has named component exports`, () => { expect(Object.entries(lib).length).toBeGreaterThan(8) }) diff --git a/tests/unit/periodic-table.test.ts b/tests/unit/periodic-table.test.ts index 23106c5..a846c41 100644 --- a/tests/unit/periodic-table.test.ts +++ b/tests/unit/periodic-table.test.ts @@ -71,17 +71,17 @@ describe(`PeriodicTable`, () => { await sleep() const selected = doc_query(`div.multiselect > ul.selected`) - const heatmap_label = `Atomic Mass (u)` + const heatmap_label = `Atomic Radius (Å)` expect(selected.textContent?.trim()).toBe(heatmap_label) const heatmap_key = heatmap_labels[heatmap_label] - expect(heatmap_key).toBe(`atomic_mass`) + expect(heatmap_key).toBe(`atomic_radius`) ptable.$set({ heatmap_values: element_data.map((e) => e[heatmap_key]) }) await sleep() const element_tile = doc_query(`div.element-tile`) // hydrogen with lowest mass should be blue (low end of color scale) - expect(element_tile.style.backgroundColor).toBe(`rgb(0, 0, 255)`) + expect(element_tile.style.backgroundColor).toBe(`rgb(27, 0, 228)`) }) test.each([[0], [0.5], [1], [2]])(