Skip to content

Commit

Permalink
split ScatterPlot into data-agnostic ScatterPlot and ElementScatter
Browse files Browse the repository at this point in the history
  • Loading branch information
janosh committed Feb 13, 2023
1 parent a2dff38 commit ea7e57f
Show file tree
Hide file tree
Showing 12 changed files with 141 additions and 88 deletions.
1 change: 1 addition & 0 deletions .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions src/lib/ElementPhoto.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand All @@ -13,7 +13,7 @@
$: src, (hidden = false) // reset hidden to false when src changes
</script>

{#if element}
{#if name && number}
<img {src} alt={name} on:error={() => (hidden = true)} {style} {hidden} />
{#if hidden && missing_msg}
<div {style}>
Expand All @@ -30,6 +30,7 @@
width: 100%;
object-fit: cover;
margin: 0;
border-radius: 4pt;
}
div {
aspect-ratio: 1;
Expand Down
50 changes: 50 additions & 0 deletions src/lib/ElementScatter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script lang="ts">
import { ScatterPlot } from '$lib'
import { element_data, type Coords } from '.'
import { pretty_num } from './labels'
import { active_element } from './stores'
// 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: number[]
export let x_label = `Atomic Number`
export let y_label: string = ``
export let y_unit: string = ``
let tooltip_point: Coords
let hovered = false
// update tooltip on hover element tile
$: if ($active_element?.number && !hovered) {
tooltip_point = {
x: $active_element.number,
y: y[$active_element.number - 1],
}
}
</script>

<ScatterPlot
{y}
x={[...Array(y.length).keys()].map((x) => x + 1)}
{...$$props}
bind:tooltip_point
bind:hovered
{x_label}
on:change
>
<div slot="tooltip" let:x let:y>
<strong>{x} - {element_data[x - 1]?.name}</strong>
<br />{y_label} = {pretty_num(y)}
{y_unit ?? ``}
</div>
</ScatterPlot>

<style>
div {
background-color: rgba(0, 0, 0, 0.7);
padding: 1pt 3pt;
width: max-content;
box-sizing: border-box;
border-radius: 3pt;
}
</style>
95 changes: 37 additions & 58 deletions src/lib/ScatterPlot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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<number, string, never> | 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)
Expand All @@ -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)
}
}
</script>
Expand All @@ -80,10 +70,7 @@
on:mouseleave={() => (hovered = false)}
on:mouseleave
>
<Line
points={scaled_data.map(([x, y]) => [x, y])}
origin={[x_scale(x_range[0]), y_scale(y_range[0])]}
/>
<Line points={scaled_data} origin={[x_scale(x_range[0]), y_scale(y_range[0])]} />
{#each scaled_data as [x, y, fill]}
<ScatterPoint {x} {y} {fill} />
{/each}
Expand All @@ -96,7 +83,7 @@
<text y={-pad_bottom + axis_label_offset.x}>{tick}</text>
</g>
{/each}
<text x={width / 2} y={height + 5 - x_label_y} class="label x">
<text x={width / 2} y={height + 5 - x_label_yshift} class="label x">
{x_label ?? ``}
</text>
</g>
Expand All @@ -120,19 +107,16 @@
</g>

{#if tooltip_point}
{@const [atomic_num, raw_y] = tooltip_point}
{@const [x, y] = [x_scale(atomic_num), y_scale(raw_y)]}
<circle cx={x} cy={y} r="5" fill="orange" />
{#if hovered}
<foreignObject x={x + 5} {y}>
<div>
<strong>{atomic_num} - {tooltip_point[2].name}</strong>
{#if raw_y}
<br />{y_label} = {pretty_num(raw_y)} {y_unit ?? ``}
{/if}
</div>
</foreignObject>
{/if}
{@const { x, y } = tooltip_point}
{@const [cx, cy] = [x_scale(x), y_scale(y)]}
<circle {cx} {cy} r="5" fill="orange" />
<!-- {#if hovered} -->
<foreignObject x={cx + 5} y={cy}>
<slot name="tooltip" {x} {y}>
({x}, {y})
</slot>
</foreignObject>
<!-- {/if} -->
{/if}
</svg>
{/if}
Expand All @@ -143,6 +127,7 @@
width: 100%;
height: 100%;
display: flex;
min-height: var(--svt-min-height, 100px);
}
svg {
width: 100%;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import {
BohrAtom,
ColorCustomizer,
ElementScatter,
ElementStats,
element_data,
PeriodicTable,
ScatterPlot,
TableInset,
} from '$lib'
import { property_labels } from '$lib/labels'
Expand All @@ -28,7 +28,7 @@

<h1>Periodic Table of Elements</h1>

<PropertySelect />
<PropertySelect empty />

{#if $last_element && window_width > 1100}
{@const { shells, name, symbol } = $last_element}
Expand All @@ -47,13 +47,13 @@
>
<TableInset slot="inset">
{#if $heatmap_key}
<ScatterPlot
<ElementScatter
y_lim={[0, null]}
y_values={element_data.map((el) => 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}
Expand Down
8 changes: 4 additions & 4 deletions src/routes/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -100,9 +100,9 @@
missing_msg={window_width < 900 ? `` : `No image for`}
/>

<!-- on:mouseleave makes ScatterPlot always show current element unless user actively hovers another element -->
<ScatterPlot
y_values={scatter_plot_values}
<!-- on:mouseleave makes ElementScatter always show current element unless user actively hovers another element -->
<ElementScatter
y={scatter_plot_values}
{y_label}
{y_unit}
{color_scale}
Expand Down
12 changes: 4 additions & 8 deletions src/site/PropertySelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
import Select from 'svelte-multiselect'
export let value: keyof ChemicalElement | null = null
export let selected: string[] = []
const options = Object.keys(heatmap_labels)
export let empty: boolean = false
export let selected: string[] = empty ? [] : [options[0]]
$: $heatmap_key = heatmap_labels[value ?? ``] ?? null
</script>

<Select
options={Object.keys(heatmap_labels)}
{selected}
maxSelect={1}
bind:value
placeholder="Select a heat map"
/>
<Select {options} {selected} maxSelect={1} bind:value placeholder="Select a heat map" />
2 changes: 1 addition & 1 deletion tests/periodic-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/element-tile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
// }
// })
})
Loading

0 comments on commit ea7e57f

Please sign in to comment.