matterviz 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FilePicker.svelte +37 -20
- package/dist/Icon.svelte +2 -2
- package/dist/app.css +29 -0
- package/dist/brillouin/BrillouinZone.svelte +19 -61
- package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
- package/dist/brillouin/BrillouinZoneExportPane.svelte +12 -20
- package/dist/brillouin/BrillouinZoneScene.svelte +2 -2
- package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
- package/dist/chempot-diagram/ChemPotDiagram.svelte +192 -0
- package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
- package/dist/chempot-diagram/ChemPotDiagram2D.svelte +677 -0
- package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
- package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2688 -0
- package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
- package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -0
- package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
- package/dist/chempot-diagram/color.d.ts +10 -0
- package/dist/chempot-diagram/color.js +33 -0
- package/dist/chempot-diagram/compute.d.ts +38 -0
- package/dist/chempot-diagram/compute.js +650 -0
- package/dist/chempot-diagram/index.d.ts +5 -0
- package/dist/chempot-diagram/index.js +5 -0
- package/dist/chempot-diagram/pointer.d.ts +16 -0
- package/dist/chempot-diagram/pointer.js +40 -0
- package/dist/chempot-diagram/temperature.d.ts +15 -0
- package/dist/chempot-diagram/temperature.js +37 -0
- package/dist/chempot-diagram/types.d.ts +83 -0
- package/dist/chempot-diagram/types.js +27 -0
- package/dist/colors/index.d.ts +3 -1
- package/dist/colors/index.js +4 -0
- package/dist/composition/BarChart.svelte +13 -22
- package/dist/composition/BubbleChart.svelte +5 -3
- package/dist/composition/FormulaFilter.svelte +586 -94
- package/dist/composition/FormulaFilter.svelte.d.ts +35 -1
- package/dist/composition/PieChart.svelte +43 -18
- package/dist/composition/PieChart.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull.svelte +4 -2
- package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull2D.svelte +13 -44
- package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull3D.svelte +16 -7
- package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull4D.svelte +17 -7
- package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHullControls.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHullStats.svelte +701 -226
- package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
- package/dist/convex-hull/ConvexHullTooltip.svelte +1 -0
- package/dist/convex-hull/demo-temperature.d.ts +6 -0
- package/dist/convex-hull/demo-temperature.js +36 -0
- package/dist/convex-hull/helpers.d.ts +1 -1
- package/dist/convex-hull/helpers.js +2 -4
- package/dist/convex-hull/index.d.ts +1 -0
- package/dist/convex-hull/index.js +1 -0
- package/dist/convex-hull/thermodynamics.d.ts +8 -21
- package/dist/convex-hull/thermodynamics.js +106 -17
- package/dist/convex-hull/types.d.ts +5 -0
- package/dist/convex-hull/types.js +5 -0
- package/dist/coordination/CoordinationBarPlot.svelte +29 -46
- package/dist/element/BohrAtom.svelte +1 -1
- package/dist/element/data.js +2 -14
- package/dist/element/data.json.gz +0 -0
- package/dist/element/types.d.ts +1 -0
- package/dist/fermi-surface/FermiSurface.svelte +20 -64
- package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
- package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
- package/dist/fermi-surface/FermiSurfaceScene.svelte +1 -1
- package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
- package/dist/fermi-surface/parse.js +16 -22
- package/dist/heatmap-matrix/HeatmapMatrix.svelte +1273 -0
- package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
- package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +171 -0
- package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +31 -0
- package/dist/heatmap-matrix/index.d.ts +53 -0
- package/dist/heatmap-matrix/index.js +100 -0
- package/dist/heatmap-matrix/shared.d.ts +2 -0
- package/dist/heatmap-matrix/shared.js +4 -0
- package/dist/icons.d.ts +111 -0
- package/dist/icons.js +111 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/io/export.js +15 -3
- package/dist/io/file-drop.d.ts +7 -0
- package/dist/io/file-drop.js +43 -0
- package/dist/io/index.d.ts +2 -2
- package/dist/io/index.js +2 -112
- package/dist/io/types.d.ts +1 -0
- package/dist/io/url-drop.d.ts +2 -0
- package/dist/io/url-drop.js +118 -0
- package/dist/isosurface/Isosurface.svelte +101 -45
- package/dist/isosurface/IsosurfaceControls.svelte +19 -0
- package/dist/isosurface/parse.js +73 -30
- package/dist/isosurface/slice.d.ts +2 -1
- package/dist/isosurface/slice.js +3 -3
- package/dist/isosurface/types.d.ts +13 -1
- package/dist/isosurface/types.js +98 -0
- package/dist/labels.d.ts +2 -1
- package/dist/labels.js +1 -0
- package/dist/layout/InfoTag.svelte +62 -62
- package/dist/layout/SubpageGrid.svelte +74 -0
- package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
- package/dist/layout/index.d.ts +1 -0
- package/dist/layout/index.js +1 -0
- package/dist/layout/json-tree/JsonNode.svelte +83 -85
- package/dist/layout/json-tree/JsonTree.svelte +20 -19
- package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
- package/dist/layout/json-tree/JsonValue.svelte +196 -116
- package/dist/layout/json-tree/types.d.ts +10 -2
- package/dist/layout/json-tree/utils.d.ts +2 -0
- package/dist/layout/json-tree/utils.js +33 -0
- package/dist/math.d.ts +7 -0
- package/dist/math.js +358 -7
- package/dist/overlays/ContextMenu.svelte +3 -2
- package/dist/overlays/DraggablePane.svelte +163 -58
- package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
- package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +232 -77
- package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
- package/dist/phase-diagram/PhaseDiagramControls.svelte +32 -11
- package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
- package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +103 -0
- package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
- package/dist/phase-diagram/PhaseDiagramExportPane.svelte +102 -95
- package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +7 -0
- package/dist/phase-diagram/PhaseDiagramTooltip.svelte +100 -26
- package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
- package/dist/phase-diagram/index.d.ts +2 -0
- package/dist/phase-diagram/index.js +2 -0
- package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
- package/dist/phase-diagram/svg-to-diagram.js +865 -0
- package/dist/phase-diagram/types.d.ts +10 -0
- package/dist/phase-diagram/utils.d.ts +7 -4
- package/dist/phase-diagram/utils.js +149 -59
- package/dist/plot/AxisLabel.svelte +26 -0
- package/dist/plot/AxisLabel.svelte.d.ts +16 -0
- package/dist/plot/BarPlot.svelte +473 -228
- package/dist/plot/BarPlot.svelte.d.ts +3 -3
- package/dist/plot/BarPlotControls.svelte +3 -2
- package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
- package/dist/plot/ColorBar.svelte +54 -54
- package/dist/plot/ColorBar.svelte.d.ts +1 -1
- package/dist/plot/ColorScaleSelect.svelte +1 -1
- package/dist/plot/ElementScatter.svelte +3 -2
- package/dist/plot/FillArea.svelte +4 -1
- package/dist/plot/Histogram.svelte +320 -230
- package/dist/plot/Histogram.svelte.d.ts +2 -2
- package/dist/plot/HistogramControls.svelte +29 -10
- package/dist/plot/HistogramControls.svelte.d.ts +6 -2
- package/dist/plot/InteractiveAxisLabel.svelte.d.ts +2 -2
- package/dist/plot/PlotControls.svelte +109 -27
- package/dist/plot/PlotControls.svelte.d.ts +1 -1
- package/dist/plot/PlotLegend.svelte +1 -1
- package/dist/plot/PortalSelect.svelte +2 -1
- package/dist/plot/ReferenceLine.svelte +2 -1
- package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
- package/dist/plot/ReferencePlane.svelte +1 -3
- package/dist/plot/ScatterPlot.svelte +343 -209
- package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
- package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
- package/dist/plot/ScatterPlot3DControls.svelte +203 -250
- package/dist/plot/ScatterPlot3DScene.svelte +4 -7
- package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
- package/dist/plot/ScatterPlotControls.svelte +95 -55
- package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
- package/dist/plot/ZeroLines.svelte +44 -0
- package/dist/plot/ZeroLines.svelte.d.ts +32 -0
- package/dist/plot/ZoomRect.svelte +21 -0
- package/dist/plot/ZoomRect.svelte.d.ts +8 -0
- package/dist/plot/axis-utils.d.ts +1 -1
- package/dist/plot/index.d.ts +6 -2
- package/dist/plot/index.js +6 -2
- package/dist/plot/interactions.d.ts +8 -10
- package/dist/plot/interactions.js +2 -3
- package/dist/plot/layout.d.ts +7 -1
- package/dist/plot/layout.js +12 -4
- package/dist/plot/reference-line.d.ts +4 -21
- package/dist/plot/reference-line.js +7 -81
- package/dist/plot/types.d.ts +42 -17
- package/dist/plot/types.js +10 -0
- package/dist/plot/utils/label-placement.js +13 -10
- package/dist/plot/utils.d.ts +1 -0
- package/dist/plot/utils.js +14 -0
- package/dist/rdf/RdfPlot.svelte +55 -66
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +17 -3
- package/dist/spectral/Bands.svelte +515 -143
- package/dist/spectral/Bands.svelte.d.ts +22 -2
- package/dist/spectral/helpers.d.ts +23 -1
- package/dist/spectral/helpers.js +65 -9
- package/dist/spectral/types.d.ts +2 -0
- package/dist/structure/AtomLegend.svelte +29 -8
- package/dist/structure/AtomLegend.svelte.d.ts +1 -1
- package/dist/structure/CellSelect.svelte +92 -22
- package/dist/structure/Structure.svelte +108 -118
- package/dist/structure/Structure.svelte.d.ts +1 -1
- package/dist/structure/StructureControls.svelte +25 -22
- package/dist/structure/StructureControls.svelte.d.ts +1 -1
- package/dist/structure/StructureInfoPane.svelte +7 -1
- package/dist/structure/StructureScene.svelte +104 -66
- package/dist/structure/StructureScene.svelte.d.ts +2 -1
- package/dist/structure/atom-properties.d.ts +6 -2
- package/dist/structure/atom-properties.js +38 -25
- package/dist/structure/export.js +10 -7
- package/dist/structure/ferrox-wasm-types.d.ts +3 -2
- package/dist/structure/ferrox-wasm-types.js +0 -3
- package/dist/structure/ferrox-wasm.d.ts +3 -2
- package/dist/structure/ferrox-wasm.js +1 -2
- package/dist/structure/index.d.ts +6 -0
- package/dist/structure/index.js +22 -0
- package/dist/structure/parse.js +19 -16
- package/dist/structure/partial-occupancy.d.ts +25 -0
- package/dist/structure/partial-occupancy.js +102 -0
- package/dist/structure/validation.js +6 -3
- package/dist/symmetry/SymmetryStats.svelte +18 -4
- package/dist/symmetry/WyckoffTable.svelte +18 -10
- package/dist/symmetry/index.d.ts +7 -4
- package/dist/symmetry/index.js +83 -18
- package/dist/table/HeatmapTable.svelte +425 -65
- package/dist/table/HeatmapTable.svelte.d.ts +12 -1
- package/dist/table/ToggleMenu.svelte +2 -0
- package/dist/table/index.d.ts +2 -0
- package/dist/trajectory/Trajectory.svelte +147 -145
- package/dist/trajectory/TrajectoryExportPane.svelte +13 -9
- package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
- package/dist/trajectory/constants.d.ts +6 -0
- package/dist/trajectory/constants.js +7 -0
- package/dist/trajectory/extract.js +3 -5
- package/dist/trajectory/format-detect.d.ts +9 -0
- package/dist/trajectory/format-detect.js +76 -0
- package/dist/trajectory/frame-reader.d.ts +17 -0
- package/dist/trajectory/frame-reader.js +339 -0
- package/dist/trajectory/helpers.d.ts +15 -0
- package/dist/trajectory/helpers.js +187 -0
- package/dist/trajectory/index.d.ts +1 -0
- package/dist/trajectory/index.js +11 -4
- package/dist/trajectory/parse/ase.d.ts +2 -0
- package/dist/trajectory/parse/ase.js +76 -0
- package/dist/trajectory/parse/hdf5.d.ts +2 -0
- package/dist/trajectory/parse/hdf5.js +121 -0
- package/dist/trajectory/parse/index.d.ts +12 -0
- package/dist/trajectory/parse/index.js +304 -0
- package/dist/trajectory/parse/lammps.d.ts +5 -0
- package/dist/trajectory/parse/lammps.js +169 -0
- package/dist/trajectory/parse/vasp.d.ts +2 -0
- package/dist/trajectory/parse/vasp.js +65 -0
- package/dist/trajectory/parse/xyz.d.ts +2 -0
- package/dist/trajectory/parse/xyz.js +109 -0
- package/dist/trajectory/types.d.ts +11 -0
- package/dist/trajectory/types.js +1 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/dist/xrd/XrdPlot.svelte +6 -4
- package/dist/xrd/calc-xrd.js +0 -1
- package/package.json +30 -24
- package/readme.md +4 -4
- package/dist/trajectory/parse.d.ts +0 -42
- package/dist/trajectory/parse.js +0 -1267
- /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
|
@@ -0,0 +1,2688 @@
|
|
|
1
|
+
<script lang="ts">import {} from '../colors';
|
|
2
|
+
import { get_hill_formula } from '../composition/format';
|
|
3
|
+
import { extract_formula_elements } from '../composition/parse';
|
|
4
|
+
import TemperatureSlider from '../convex-hull/TemperatureSlider.svelte';
|
|
5
|
+
import Icon from '../Icon.svelte';
|
|
6
|
+
import { format_num } from '../labels';
|
|
7
|
+
import { set_fullscreen_bg, SettingsSection, toggle_fullscreen } from '../layout';
|
|
8
|
+
import { convex_hull_2d, cross_3d, merge_coplanar_triangles, normalize_vec3, } from '../math';
|
|
9
|
+
import DraggablePane from '../overlays/DraggablePane.svelte';
|
|
10
|
+
import { ColorBar, ScatterPlot3DControls } from '../plot';
|
|
11
|
+
import { Canvas, T } from '@threlte/core';
|
|
12
|
+
import * as extras from '@threlte/extras';
|
|
13
|
+
import { scaleLinear } from 'd3-scale';
|
|
14
|
+
import { onDestroy, onMount, untrack } from 'svelte';
|
|
15
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
16
|
+
import * as THREE from 'three';
|
|
17
|
+
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
|
18
|
+
import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js';
|
|
19
|
+
import ChemPotScene3D from './ChemPotScene3D.svelte';
|
|
20
|
+
import { get_chempot_color_bar_config, make_chempot_color_scale } from './color';
|
|
21
|
+
import { apply_element_padding, best_form_energy_for_formula, build_axis_ranges, compute_chempot_diagram, dedup_points, formula_key_from_composition, get_3d_domain_simplexes_and_ann_loc, get_energy_per_atom, get_min_entries_and_el_refs, pad_domain_points, } from './compute';
|
|
22
|
+
import { with_hover_pointer } from './pointer';
|
|
23
|
+
import { get_projection_source_entries, get_temp_filter_payload, get_valid_temperature, } from './temperature';
|
|
24
|
+
import { CHEMPOT_DEFAULTS } from './types';
|
|
25
|
+
let { entries = [], config = {}, width = $bindable(800), height = $bindable(600),
|
|
26
|
+
// Auto-corrected to a valid available temperature when needed.
|
|
27
|
+
temperature = $bindable(undefined), interpolate_temperature = CHEMPOT_DEFAULTS.interpolate_temperature, max_interpolation_gap = CHEMPOT_DEFAULTS.max_interpolation_gap, hover_info = $bindable(null), render_local_tooltip = true, } = $props();
|
|
28
|
+
let formal_chempots_override = $state(null);
|
|
29
|
+
let label_stable_override = $state(null);
|
|
30
|
+
let element_padding_override = $state(null);
|
|
31
|
+
let default_min_limit_override = $state(null);
|
|
32
|
+
let draw_formula_meshes_override = $state(null);
|
|
33
|
+
let draw_formula_lines_override = $state(null);
|
|
34
|
+
const formal_chempots = $derived(formal_chempots_override ??
|
|
35
|
+
(config.formal_chempots ?? CHEMPOT_DEFAULTS.formal_chempots));
|
|
36
|
+
const label_stable = $derived(label_stable_override ?? (config.label_stable ?? CHEMPOT_DEFAULTS.label_stable));
|
|
37
|
+
const element_padding = $derived(element_padding_override ??
|
|
38
|
+
(config.element_padding ?? CHEMPOT_DEFAULTS.element_padding));
|
|
39
|
+
const default_min_limit = $derived(default_min_limit_override ??
|
|
40
|
+
(config.default_min_limit ?? CHEMPOT_DEFAULTS.default_min_limit));
|
|
41
|
+
let formulas_to_draw_override = $state(null);
|
|
42
|
+
const formulas_to_draw = $derived(formulas_to_draw_override ?? (config.formulas_to_draw ?? []));
|
|
43
|
+
const draw_formula_meshes = $derived(draw_formula_meshes_override ??
|
|
44
|
+
(config.draw_formula_meshes ?? CHEMPOT_DEFAULTS.draw_formula_meshes));
|
|
45
|
+
const draw_formula_lines = $derived(draw_formula_lines_override ??
|
|
46
|
+
(config.draw_formula_lines ?? CHEMPOT_DEFAULTS.draw_formula_lines));
|
|
47
|
+
let color_mode_override = $state(null);
|
|
48
|
+
let color_scale_override = $state(null);
|
|
49
|
+
let reverse_color_scale_override = $state(null);
|
|
50
|
+
const color_mode = $derived(color_mode_override ?? (config.color_mode ?? `arity`));
|
|
51
|
+
const color_scale = $derived(color_scale_override ?? (config.color_scale ?? CHEMPOT_DEFAULTS.color_scale));
|
|
52
|
+
const reverse_color_scale = $derived(reverse_color_scale_override ??
|
|
53
|
+
(config.reverse_color_scale ?? CHEMPOT_DEFAULTS.reverse_color_scale));
|
|
54
|
+
const show_tooltip = $derived(config.show_tooltip ?? CHEMPOT_DEFAULTS.show_tooltip);
|
|
55
|
+
const tooltip_detail_level = $derived(config.tooltip_detail_level ?? CHEMPOT_DEFAULTS.tooltip_detail_level);
|
|
56
|
+
const formula_colors = $derived(config.formula_colors?.length
|
|
57
|
+
? config.formula_colors
|
|
58
|
+
: CHEMPOT_DEFAULTS.formula_colors);
|
|
59
|
+
function normalize_projection_triplet(maybe_triplet, available_elements) {
|
|
60
|
+
if (!maybe_triplet || maybe_triplet.length !== 3)
|
|
61
|
+
return null;
|
|
62
|
+
const deduped = Array.from(new Set(maybe_triplet));
|
|
63
|
+
if (deduped.length !== 3)
|
|
64
|
+
return null;
|
|
65
|
+
if (deduped.some((element) => !available_elements.includes(element)))
|
|
66
|
+
return null;
|
|
67
|
+
return deduped;
|
|
68
|
+
}
|
|
69
|
+
let wrapper = $state();
|
|
70
|
+
let fullscreen = $state(false);
|
|
71
|
+
let export_pane_open = $state(false);
|
|
72
|
+
let formula_picker_open = $state(false);
|
|
73
|
+
let copy_status = $state(false);
|
|
74
|
+
let copy_timeout_id = null;
|
|
75
|
+
let container_width = $state(0);
|
|
76
|
+
let container_height = $state(0);
|
|
77
|
+
const base_aspect_ratio = $derived(height > 0 && width > 0 ? height / width : 1);
|
|
78
|
+
const render_width = $derived(container_width > 0 ? container_width : width);
|
|
79
|
+
const render_height = $derived(fullscreen
|
|
80
|
+
? (container_height > 0 ? container_height : height)
|
|
81
|
+
: Math.round(render_width * base_aspect_ratio));
|
|
82
|
+
let mounted = $state(false);
|
|
83
|
+
onMount(() => mounted = true);
|
|
84
|
+
let fixed_container_element = $state(null);
|
|
85
|
+
let fixed_container_rect = $state(null);
|
|
86
|
+
let fixed_container_frame_id = null;
|
|
87
|
+
let orbit_controls_ref = $state(undefined);
|
|
88
|
+
// Backside tracking: axes/ticks/labels render on the far side from the camera
|
|
89
|
+
// back[i] = backside data coordinate value for data axis i
|
|
90
|
+
// Matches ScatterPlot3DScene pattern where pos tracks the opposite side from camera
|
|
91
|
+
let back = $state([0, 0, 0]);
|
|
92
|
+
// Outward offset signs for tick/label placement (away from bounding box)
|
|
93
|
+
let out_x = $state(-1); // sign for Three.js X (data axis 1) direction
|
|
94
|
+
let out_y = $state(-1); // sign for Three.js Y (data axis 2) direction
|
|
95
|
+
let camera_projection = $state(`orthographic`);
|
|
96
|
+
let auto_rotate = $state(0);
|
|
97
|
+
let display = $state({
|
|
98
|
+
show_axes: true,
|
|
99
|
+
show_grid: true,
|
|
100
|
+
show_axis_labels: true,
|
|
101
|
+
show_bounding_box: false,
|
|
102
|
+
projections: { xy: false, xz: false, yz: false },
|
|
103
|
+
projection_opacity: 0.15,
|
|
104
|
+
projection_scale: 0.5,
|
|
105
|
+
});
|
|
106
|
+
let x_axis = $state({ label: ``, range: [null, null] });
|
|
107
|
+
let y_axis = $state({ label: ``, range: [null, null] });
|
|
108
|
+
let z_axis = $state({ label: ``, range: [null, null] });
|
|
109
|
+
const projection_opacity = $derived(display.projection_opacity ?? 0.15);
|
|
110
|
+
// Plotly/pymatgen uses Z-up with x-axis projecting left in isometric view.
|
|
111
|
+
// Three.js uses Y-up with X projecting right. To match pymatgen's visual layout:
|
|
112
|
+
// data[0] (plotly x, projects left) → Three.js Z (projects left)
|
|
113
|
+
// data[1] (plotly y, projects right) → Three.js X (projects right)
|
|
114
|
+
// data[2] (plotly z, projects up) → Three.js Y (projects up)
|
|
115
|
+
function to_vec3(pt) {
|
|
116
|
+
const [x_val, y_val, z_val] = to_render_xyz(pt);
|
|
117
|
+
return new THREE.Vector3(x_val, y_val, z_val);
|
|
118
|
+
}
|
|
119
|
+
// Compute diagram data (requires >= 3 elements for 3D rendering)
|
|
120
|
+
const { has_temp_data, available_temperatures, temp_filtered_entries } = $derived(get_temp_filter_payload(entries, temperature, config, {
|
|
121
|
+
interpolate_temperature,
|
|
122
|
+
max_interpolation_gap,
|
|
123
|
+
}));
|
|
124
|
+
// Keep bound temperature aligned with available data points.
|
|
125
|
+
$effect(() => {
|
|
126
|
+
const next_temperature = get_valid_temperature(temperature, has_temp_data, available_temperatures);
|
|
127
|
+
if (next_temperature !== temperature)
|
|
128
|
+
temperature = next_temperature;
|
|
129
|
+
});
|
|
130
|
+
const show_temperature_slider = $derived(has_temp_data && available_temperatures.length > 0);
|
|
131
|
+
const projection_source_entries = $derived(get_projection_source_entries(entries, temp_filtered_entries));
|
|
132
|
+
const all_entry_elements = $derived.by(() => Array.from(new SvelteSet(projection_source_entries.flatMap((entry) => Object.entries(entry.composition)
|
|
133
|
+
.filter(([, amount]) => amount > 0)
|
|
134
|
+
.map(([element]) => element)))).sort());
|
|
135
|
+
const has_multinary_system = $derived(all_entry_elements.length > 3);
|
|
136
|
+
let projection_elements_override = $state(null);
|
|
137
|
+
const config_projection_elements = $derived(normalize_projection_triplet(config.elements, all_entry_elements));
|
|
138
|
+
const projection_elements = $derived.by(() => {
|
|
139
|
+
if (all_entry_elements.length < 3)
|
|
140
|
+
return [];
|
|
141
|
+
if (!has_multinary_system) {
|
|
142
|
+
return config_projection_elements ?? all_entry_elements.slice(0, 3);
|
|
143
|
+
}
|
|
144
|
+
const override_projection = normalize_projection_triplet(projection_elements_override ?? undefined, all_entry_elements);
|
|
145
|
+
if (override_projection)
|
|
146
|
+
return override_projection;
|
|
147
|
+
if (config_projection_elements)
|
|
148
|
+
return config_projection_elements;
|
|
149
|
+
return all_entry_elements.slice(0, 3);
|
|
150
|
+
});
|
|
151
|
+
const effective_config = $derived({
|
|
152
|
+
...config,
|
|
153
|
+
elements: projection_elements.length === 3
|
|
154
|
+
? projection_elements
|
|
155
|
+
: config.elements,
|
|
156
|
+
formal_chempots,
|
|
157
|
+
label_stable,
|
|
158
|
+
element_padding,
|
|
159
|
+
default_min_limit,
|
|
160
|
+
draw_formula_meshes,
|
|
161
|
+
draw_formula_lines,
|
|
162
|
+
});
|
|
163
|
+
const diagram_data = $derived.by(() => {
|
|
164
|
+
if (temp_filtered_entries.length < 3)
|
|
165
|
+
return null;
|
|
166
|
+
try {
|
|
167
|
+
const data = compute_chempot_diagram(temp_filtered_entries, effective_config);
|
|
168
|
+
return data.elements.length >= 3 ? data : null;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
console.error(`ChemPotDiagram3D:`, err);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const plot_elements = $derived(diagram_data?.elements ?? projection_elements);
|
|
176
|
+
const is_projection_mode = $derived(plot_elements.length > 0 &&
|
|
177
|
+
plot_elements.length < all_entry_elements.length &&
|
|
178
|
+
plot_elements.every((element) => all_entry_elements.includes(element)));
|
|
179
|
+
const projection_presets = $derived.by(() => {
|
|
180
|
+
const presets = [];
|
|
181
|
+
const seen_triplets = new SvelteSet();
|
|
182
|
+
const add_triplet = (candidate) => {
|
|
183
|
+
if (!candidate)
|
|
184
|
+
return;
|
|
185
|
+
const key = candidate.join(`|`);
|
|
186
|
+
if (seen_triplets.has(key))
|
|
187
|
+
return;
|
|
188
|
+
seen_triplets.add(key);
|
|
189
|
+
presets.push(candidate);
|
|
190
|
+
};
|
|
191
|
+
add_triplet(config_projection_elements);
|
|
192
|
+
add_triplet(plot_elements.length === 3 ? plot_elements : null);
|
|
193
|
+
if (all_entry_elements.length >= 3) {
|
|
194
|
+
const n_elements = all_entry_elements.length;
|
|
195
|
+
for (let first_idx = 0; first_idx < n_elements; first_idx++) {
|
|
196
|
+
for (let second_idx = first_idx + 1; second_idx < n_elements; second_idx++) {
|
|
197
|
+
for (let third_idx = second_idx + 1; third_idx < n_elements; third_idx++) {
|
|
198
|
+
add_triplet([
|
|
199
|
+
all_entry_elements[first_idx],
|
|
200
|
+
all_entry_elements[second_idx],
|
|
201
|
+
all_entry_elements[third_idx],
|
|
202
|
+
]);
|
|
203
|
+
if (presets.length >= 12)
|
|
204
|
+
return presets;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return presets;
|
|
210
|
+
});
|
|
211
|
+
const current_projection_key = $derived(plot_elements.join(`|`));
|
|
212
|
+
let formula_filter_query = $state(``);
|
|
213
|
+
const available_formulas = $derived.by(() => Object.keys(diagram_data?.domains ?? {}).sort());
|
|
214
|
+
const filtered_formulas = $derived.by(() => {
|
|
215
|
+
const query = formula_filter_query.trim().toLowerCase();
|
|
216
|
+
if (!query)
|
|
217
|
+
return available_formulas;
|
|
218
|
+
return available_formulas.filter((formula) => formula.toLowerCase().includes(query));
|
|
219
|
+
});
|
|
220
|
+
const render_domains = $derived.by(() => {
|
|
221
|
+
if (!diagram_data || plot_elements.length < 2)
|
|
222
|
+
return [];
|
|
223
|
+
const dim = diagram_data.elements.length;
|
|
224
|
+
const indices = Array.from({ length: dim }, (_, idx) => idx);
|
|
225
|
+
const new_lims = element_padding > 0
|
|
226
|
+
? apply_element_padding(diagram_data.domains, indices, element_padding, default_min_limit)
|
|
227
|
+
: null;
|
|
228
|
+
const result = [];
|
|
229
|
+
for (const [formula, pts] of Object.entries(diagram_data.domains)) {
|
|
230
|
+
const padded = new_lims
|
|
231
|
+
? pad_domain_points(pts, indices, new_lims, default_min_limit, element_padding)
|
|
232
|
+
: pts;
|
|
233
|
+
if (padded.length < 2)
|
|
234
|
+
continue;
|
|
235
|
+
const is_draw = formulas_to_draw.includes(formula);
|
|
236
|
+
const label_loc = padded[0].map((_, col_idx) => padded.reduce((sum, point) => sum + point[col_idx], 0) / padded.length);
|
|
237
|
+
if (padded.length >= 3) {
|
|
238
|
+
const { ann_loc } = get_3d_domain_simplexes_and_ann_loc(padded);
|
|
239
|
+
result.push({
|
|
240
|
+
formula,
|
|
241
|
+
points_3d: padded,
|
|
242
|
+
ann_loc,
|
|
243
|
+
label_loc,
|
|
244
|
+
is_draw_formula: is_draw,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
result.push({
|
|
249
|
+
formula,
|
|
250
|
+
points_3d: padded,
|
|
251
|
+
ann_loc: label_loc,
|
|
252
|
+
label_loc,
|
|
253
|
+
is_draw_formula: is_draw,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
});
|
|
259
|
+
const entry_energy_stats_by_formula = $derived.by(() => {
|
|
260
|
+
const stats_by_formula = new SvelteMap();
|
|
261
|
+
for (const entry of temp_filtered_entries) {
|
|
262
|
+
const formula_key = formula_key_from_composition(entry.composition);
|
|
263
|
+
const energy_per_atom = get_energy_per_atom(entry);
|
|
264
|
+
const existing = stats_by_formula.get(formula_key);
|
|
265
|
+
if (!existing) {
|
|
266
|
+
stats_by_formula.set(formula_key, {
|
|
267
|
+
matching_entry_count: 1,
|
|
268
|
+
min_energy_per_atom: energy_per_atom,
|
|
269
|
+
max_energy_per_atom: energy_per_atom,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
stats_by_formula.set(formula_key, {
|
|
274
|
+
matching_entry_count: existing.matching_entry_count + 1,
|
|
275
|
+
min_energy_per_atom: Math.min(existing.min_energy_per_atom ?? energy_per_atom, energy_per_atom),
|
|
276
|
+
max_energy_per_atom: Math.max(existing.max_energy_per_atom ?? energy_per_atom, energy_per_atom),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return stats_by_formula;
|
|
280
|
+
});
|
|
281
|
+
// === Region coloring ===
|
|
282
|
+
// Categorical palette for arity mode (element count)
|
|
283
|
+
const arity_colors = [`#3498db`, `#2ecc71`, `#e67e22`, `#9b59b6`];
|
|
284
|
+
// Original (non-renormalized) elemental references for formation energy computation.
|
|
285
|
+
// diagram_data.el_refs may be renormalized to zero when formal_chempots is true,
|
|
286
|
+
// so we compute our own from the raw entries to get true DFT reference energies.
|
|
287
|
+
const raw_el_refs = $derived(get_min_entries_and_el_refs(temp_filtered_entries).el_refs);
|
|
288
|
+
const color_mode_labels = {
|
|
289
|
+
energy: `Energy per atom (eV)`,
|
|
290
|
+
formation_energy: `Formation energy (eV/atom)`,
|
|
291
|
+
entries: `Entry count`,
|
|
292
|
+
};
|
|
293
|
+
function get_numeric_color_value(formula, active_color_mode) {
|
|
294
|
+
if (active_color_mode === `energy`) {
|
|
295
|
+
return entry_energy_stats_by_formula.get(formula)?.min_energy_per_atom ?? null;
|
|
296
|
+
}
|
|
297
|
+
if (active_color_mode === `formation_energy`) {
|
|
298
|
+
return best_form_energy_for_formula(temp_filtered_entries, formula, raw_el_refs) ?? null;
|
|
299
|
+
}
|
|
300
|
+
return entry_energy_stats_by_formula.get(formula)?.matching_entry_count ?? 0;
|
|
301
|
+
}
|
|
302
|
+
const domain_color_values = $derived.by(() => {
|
|
303
|
+
if (color_mode === `none` || color_mode === `arity`)
|
|
304
|
+
return null;
|
|
305
|
+
const active_color_mode = color_mode;
|
|
306
|
+
const value_by_formula = new SvelteMap();
|
|
307
|
+
const values = [];
|
|
308
|
+
for (const domain of render_domains) {
|
|
309
|
+
const value = get_numeric_color_value(domain.formula, active_color_mode);
|
|
310
|
+
if (value == null || !Number.isFinite(value))
|
|
311
|
+
continue;
|
|
312
|
+
values.push(value);
|
|
313
|
+
value_by_formula.set(domain.formula, value);
|
|
314
|
+
}
|
|
315
|
+
return { value_by_formula, values };
|
|
316
|
+
});
|
|
317
|
+
// Per-domain color map keyed by formula
|
|
318
|
+
const domain_colors = $derived.by(() => {
|
|
319
|
+
const colors = new SvelteMap();
|
|
320
|
+
if (color_mode === `none`)
|
|
321
|
+
return colors;
|
|
322
|
+
if (color_mode === `arity`) {
|
|
323
|
+
for (const domain of render_domains) {
|
|
324
|
+
const n_elements = extract_formula_elements(domain.formula).length;
|
|
325
|
+
const idx = Math.min(n_elements, arity_colors.length) - 1;
|
|
326
|
+
colors.set(domain.formula, arity_colors[Math.max(0, idx)]);
|
|
327
|
+
}
|
|
328
|
+
return colors;
|
|
329
|
+
}
|
|
330
|
+
const values_payload = domain_color_values;
|
|
331
|
+
const scale = make_chempot_color_scale(values_payload?.values ?? [], color_scale, reverse_color_scale);
|
|
332
|
+
for (const domain of render_domains) {
|
|
333
|
+
const value = values_payload?.value_by_formula.get(domain.formula);
|
|
334
|
+
colors.set(domain.formula, value != null && scale ? scale(value) : `#999`);
|
|
335
|
+
}
|
|
336
|
+
return colors;
|
|
337
|
+
});
|
|
338
|
+
// Range and label for the color bar (null for none/arity which are categorical)
|
|
339
|
+
const color_range = $derived.by(() => {
|
|
340
|
+
const values = domain_color_values?.values ?? [];
|
|
341
|
+
if (values.length === 0)
|
|
342
|
+
return null;
|
|
343
|
+
let lo = values[0], hi = values[0];
|
|
344
|
+
for (let idx = 1; idx < values.length; idx++) {
|
|
345
|
+
if (values[idx] < lo)
|
|
346
|
+
lo = values[idx];
|
|
347
|
+
if (values[idx] > hi)
|
|
348
|
+
hi = values[idx];
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
min: lo,
|
|
352
|
+
max: Math.max(hi, lo + 1e-6),
|
|
353
|
+
label: color_mode === `none` || color_mode === `arity`
|
|
354
|
+
? ``
|
|
355
|
+
: color_mode_labels[color_mode],
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
const arity_legend_labels = $derived.by(() => {
|
|
359
|
+
let has_four_plus_regions = false;
|
|
360
|
+
for (const domain of render_domains) {
|
|
361
|
+
if (extract_formula_elements(domain.formula).length >= 4) {
|
|
362
|
+
has_four_plus_regions = true;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return has_four_plus_regions
|
|
367
|
+
? [`Unary`, `Binary`, `Ternary`, `4+`]
|
|
368
|
+
: [`Unary`, `Binary`, `Ternary`];
|
|
369
|
+
});
|
|
370
|
+
// Stretch short axes to improve screen-space utilization for highly anisotropic systems.
|
|
371
|
+
// Mapping is in rendered axis order: X=data[1], Y=data[2], Z=data[0].
|
|
372
|
+
const render_axis_scale = $derived.by(() => {
|
|
373
|
+
const points = render_domains.flatMap((domain) => domain.points_3d);
|
|
374
|
+
if (points.length === 0)
|
|
375
|
+
return [1, 1, 1];
|
|
376
|
+
let min0 = Infinity, max0 = -Infinity;
|
|
377
|
+
let min1 = Infinity, max1 = -Infinity;
|
|
378
|
+
let min2 = Infinity, max2 = -Infinity;
|
|
379
|
+
for (const point of points) {
|
|
380
|
+
if (point[0] < min0)
|
|
381
|
+
min0 = point[0];
|
|
382
|
+
if (point[0] > max0)
|
|
383
|
+
max0 = point[0];
|
|
384
|
+
if (point[1] < min1)
|
|
385
|
+
min1 = point[1];
|
|
386
|
+
if (point[1] > max1)
|
|
387
|
+
max1 = point[1];
|
|
388
|
+
if (point[2] < min2)
|
|
389
|
+
min2 = point[2];
|
|
390
|
+
if (point[2] > max2)
|
|
391
|
+
max2 = point[2];
|
|
392
|
+
}
|
|
393
|
+
const span_x = Math.max(max1 - min1, 1e-6); // render X from data axis 1
|
|
394
|
+
const span_y = Math.max(max2 - min2, 1e-6); // render Y from data axis 2
|
|
395
|
+
const span_z = Math.max(max0 - min0, 1e-6); // render Z from data axis 0
|
|
396
|
+
const max_span = Math.max(span_x, span_y, span_z);
|
|
397
|
+
return [
|
|
398
|
+
Math.min(Math.max(max_span / span_x, 1), 4),
|
|
399
|
+
Math.min(Math.max(max_span / span_y, 1), 4),
|
|
400
|
+
Math.min(Math.max(max_span / span_z, 1), 4),
|
|
401
|
+
];
|
|
402
|
+
});
|
|
403
|
+
function to_render_xyz(point) {
|
|
404
|
+
const [scale_x, scale_y, scale_z] = render_axis_scale;
|
|
405
|
+
return [point[1] * scale_x, point[2] * scale_y, point[0] * scale_z];
|
|
406
|
+
}
|
|
407
|
+
// Compute data center and extent for camera positioning (in swizzled coords)
|
|
408
|
+
const { data_center, data_extent } = $derived.by(() => {
|
|
409
|
+
const points = render_domains.flatMap((domain) => domain.points_3d);
|
|
410
|
+
if (points.length === 0) {
|
|
411
|
+
return { data_center: new THREE.Vector3(0, 0, 0), data_extent: 10 };
|
|
412
|
+
}
|
|
413
|
+
// Compute center in rendered coordinates (swizzled + axis scaling).
|
|
414
|
+
let [sum_x, sum_y, sum_z] = [0, 0, 0];
|
|
415
|
+
for (const point_3d of points) {
|
|
416
|
+
const [x_val, y_val, z_val] = to_render_xyz(point_3d);
|
|
417
|
+
sum_x += x_val;
|
|
418
|
+
sum_y += y_val;
|
|
419
|
+
sum_z += z_val;
|
|
420
|
+
}
|
|
421
|
+
const n_points = points.length;
|
|
422
|
+
const center = new THREE.Vector3(sum_x / n_points, sum_y / n_points, sum_z / n_points);
|
|
423
|
+
// Compute max distance from center
|
|
424
|
+
let max_dist = 0;
|
|
425
|
+
for (const point of points) {
|
|
426
|
+
const [x_val, y_val, z_val] = to_render_xyz(point);
|
|
427
|
+
const dist = Math.hypot(x_val - center.x, y_val - center.y, z_val - center.z);
|
|
428
|
+
if (dist > max_dist)
|
|
429
|
+
max_dist = dist;
|
|
430
|
+
}
|
|
431
|
+
return { data_center: center, data_extent: Math.max(max_dist * 1.3, 1) };
|
|
432
|
+
});
|
|
433
|
+
const default_camera_position = $derived([
|
|
434
|
+
data_center.x + data_extent,
|
|
435
|
+
data_center.y + data_extent,
|
|
436
|
+
data_center.z + data_extent,
|
|
437
|
+
]);
|
|
438
|
+
const default_camera_target = $derived([
|
|
439
|
+
data_center.x,
|
|
440
|
+
data_center.y,
|
|
441
|
+
data_center.z,
|
|
442
|
+
]);
|
|
443
|
+
const default_orthographic_zoom = $derived(Math.min(render_width, render_height) / (data_extent * 1.6));
|
|
444
|
+
let camera_position_override = $state(null);
|
|
445
|
+
let camera_target_override = $state(null);
|
|
446
|
+
let orthographic_zoom_override = $state(null);
|
|
447
|
+
const camera_position = $derived(camera_position_override ?? default_camera_position);
|
|
448
|
+
const camera_target = $derived(camera_target_override ?? default_camera_target);
|
|
449
|
+
const orthographic_zoom = $derived(orthographic_zoom_override ?? default_orthographic_zoom);
|
|
450
|
+
let last_data_center = null;
|
|
451
|
+
let last_data_extent = null;
|
|
452
|
+
// Compute domain boundary edges via axis-aligned 2D convex hull projection.
|
|
453
|
+
// Each domain in a chem pot diagram is a convex polygon/polyhedron. We project
|
|
454
|
+
// to 2D (trying all 3 axis-aligned planes) and use the best projection's
|
|
455
|
+
// convex hull boundary. This reliably handles both flat and 3D domains.
|
|
456
|
+
function get_domain_edges(pts) {
|
|
457
|
+
const unique = dedup_3d(pts);
|
|
458
|
+
if (unique.length < 2)
|
|
459
|
+
return [];
|
|
460
|
+
if (unique.length === 2)
|
|
461
|
+
return [[unique[0], unique[1]]];
|
|
462
|
+
if (unique.length === 3) {
|
|
463
|
+
return [[unique[0], unique[1]], [unique[1], unique[2]], [unique[0], unique[2]]];
|
|
464
|
+
}
|
|
465
|
+
return get_2d_hull_edges(unique);
|
|
466
|
+
}
|
|
467
|
+
function polygon_area_2d(points_2d) {
|
|
468
|
+
if (points_2d.length < 3)
|
|
469
|
+
return 0;
|
|
470
|
+
let area_twice = 0;
|
|
471
|
+
for (let idx = 0; idx < points_2d.length; idx++) {
|
|
472
|
+
const current = points_2d[idx];
|
|
473
|
+
const next = points_2d[(idx + 1) % points_2d.length];
|
|
474
|
+
area_twice += current[0] * next[1] - next[0] * current[1];
|
|
475
|
+
}
|
|
476
|
+
return Math.abs(area_twice) / 2;
|
|
477
|
+
}
|
|
478
|
+
// Compute domain edges from the single best axis-aligned projection
|
|
479
|
+
// (largest non-degenerate hull area). Unioning multiple projections can add
|
|
480
|
+
// non-physical diagonals for nearly coplanar domains.
|
|
481
|
+
// Called only from get_domain_edges with 4+ unique points
|
|
482
|
+
function get_2d_hull_edges(pts) {
|
|
483
|
+
let selected_hull = [];
|
|
484
|
+
let selected_coord_to_idx = null;
|
|
485
|
+
let selected_hull_area = -1;
|
|
486
|
+
for (const drop of [0, 1, 2]) {
|
|
487
|
+
const axes = [0, 1, 2].filter((ax) => ax !== drop);
|
|
488
|
+
// Skip this projection if points collapse to a line (near-zero range in
|
|
489
|
+
// either projected axis). This avoids spurious edges from edge-on views.
|
|
490
|
+
let min0 = Infinity, max0 = -Infinity, min1 = Infinity, max1 = -Infinity;
|
|
491
|
+
for (const pt of pts) {
|
|
492
|
+
const v0 = pt[axes[0]], v1 = pt[axes[1]];
|
|
493
|
+
if (v0 < min0)
|
|
494
|
+
min0 = v0;
|
|
495
|
+
if (v0 > max0)
|
|
496
|
+
max0 = v0;
|
|
497
|
+
if (v1 < min1)
|
|
498
|
+
min1 = v1;
|
|
499
|
+
if (v1 > max1)
|
|
500
|
+
max1 = v1;
|
|
501
|
+
}
|
|
502
|
+
const range0 = max0 - min0, range1 = max1 - min1;
|
|
503
|
+
const max_2d_range = Math.max(range0, range1);
|
|
504
|
+
if (max_2d_range < 1e-6 || Math.min(range0, range1) < max_2d_range * 0.01) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// Build coordinate lookup for this projection
|
|
508
|
+
const coord_to_idx = new SvelteMap();
|
|
509
|
+
const pts_2d = [];
|
|
510
|
+
for (let idx = 0; idx < pts.length; idx++) {
|
|
511
|
+
const p2 = [pts[idx][axes[0]], pts[idx][axes[1]]];
|
|
512
|
+
pts_2d.push(p2);
|
|
513
|
+
const key = `${p2[0].toFixed(6)},${p2[1].toFixed(6)}`;
|
|
514
|
+
if (!coord_to_idx.has(key))
|
|
515
|
+
coord_to_idx.set(key, idx);
|
|
516
|
+
}
|
|
517
|
+
const hull = convex_hull_2d(pts_2d);
|
|
518
|
+
if (hull.length < 3)
|
|
519
|
+
continue;
|
|
520
|
+
const hull_area = polygon_area_2d(hull);
|
|
521
|
+
if (hull_area <= selected_hull_area)
|
|
522
|
+
continue;
|
|
523
|
+
selected_hull = hull;
|
|
524
|
+
selected_coord_to_idx = coord_to_idx;
|
|
525
|
+
selected_hull_area = hull_area;
|
|
526
|
+
}
|
|
527
|
+
if (!selected_coord_to_idx || selected_hull.length < 3)
|
|
528
|
+
return [];
|
|
529
|
+
const edges = [];
|
|
530
|
+
for (let idx = 0; idx < selected_hull.length; idx++) {
|
|
531
|
+
const point_a = selected_hull[idx];
|
|
532
|
+
const point_b = selected_hull[(idx + 1) % selected_hull.length];
|
|
533
|
+
const point_a_idx = selected_coord_to_idx.get(`${point_a[0].toFixed(6)},${point_a[1].toFixed(6)}`);
|
|
534
|
+
const point_b_idx = selected_coord_to_idx.get(`${point_b[0].toFixed(6)},${point_b[1].toFixed(6)}`);
|
|
535
|
+
if (point_a_idx == null || point_b_idx == null || point_a_idx >= pts.length ||
|
|
536
|
+
point_b_idx >= pts.length) {
|
|
537
|
+
console.warn(`get_2d_hull_edges: invalid edge`, {
|
|
538
|
+
point_a,
|
|
539
|
+
point_b,
|
|
540
|
+
point_a_idx,
|
|
541
|
+
point_b_idx,
|
|
542
|
+
});
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
edges.push([pts[point_a_idx], pts[point_b_idx]]);
|
|
546
|
+
}
|
|
547
|
+
return edges;
|
|
548
|
+
}
|
|
549
|
+
// Build globally deduplicated edge geometry for domain boundaries using
|
|
550
|
+
// 3D convex hull crease edges (not 2D projected hull).
|
|
551
|
+
const edge_geometry = $derived.by(() => {
|
|
552
|
+
if (is_projection_mode) {
|
|
553
|
+
const all_points = render_domains
|
|
554
|
+
.filter((domain) => !domain.is_draw_formula)
|
|
555
|
+
.flatMap((domain) => domain.points_3d);
|
|
556
|
+
const unique_points = dedup_3d(all_points);
|
|
557
|
+
if (unique_points.length >= 4) {
|
|
558
|
+
try {
|
|
559
|
+
const hull_vectors = unique_points.map((point) => to_vec3(point));
|
|
560
|
+
const hull_geometry = new ConvexGeometry(hull_vectors);
|
|
561
|
+
const hull_edges = new THREE.EdgesGeometry(hull_geometry);
|
|
562
|
+
hull_geometry.dispose();
|
|
563
|
+
return hull_edges;
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// Fall back to per-domain edges below.
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const seen = new SvelteSet();
|
|
571
|
+
const positions = [];
|
|
572
|
+
for (const domain of render_domains) {
|
|
573
|
+
if (domain.is_draw_formula)
|
|
574
|
+
continue;
|
|
575
|
+
// Compute edges in swizzled (Three.js) coords since ConvexGeometry works there
|
|
576
|
+
const swizzled = domain.points_3d.map((point) => to_render_xyz(point));
|
|
577
|
+
for (const [pa, pb] of get_domain_edges(swizzled)) {
|
|
578
|
+
const ka = pa.map((v) => v.toFixed(4)).join(`,`);
|
|
579
|
+
const kb = pb.map((v) => v.toFixed(4)).join(`,`);
|
|
580
|
+
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
|
581
|
+
if (seen.has(key))
|
|
582
|
+
continue;
|
|
583
|
+
seen.add(key);
|
|
584
|
+
positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2]);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const geom = new THREE.BufferGeometry();
|
|
588
|
+
geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
|
|
589
|
+
return geom;
|
|
590
|
+
});
|
|
591
|
+
// Build a single opaque convex hull mesh from ALL domain vertices for depth
|
|
592
|
+
// occlusion. This seamless surface writes to the depth buffer, hiding wireframe
|
|
593
|
+
// edges on the back side. Using all vertices together avoids gaps between domains.
|
|
594
|
+
const occlusion_hull_geometry = $derived.by(() => {
|
|
595
|
+
try {
|
|
596
|
+
const all_points = [];
|
|
597
|
+
for (const domain of render_domains) {
|
|
598
|
+
if (domain.is_draw_formula)
|
|
599
|
+
continue;
|
|
600
|
+
all_points.push(...domain.points_3d);
|
|
601
|
+
}
|
|
602
|
+
const unique_points = dedup_3d(all_points);
|
|
603
|
+
if (unique_points.length < 4)
|
|
604
|
+
return null;
|
|
605
|
+
const vectors = unique_points.map((point) => to_vec3(point));
|
|
606
|
+
return merge_coplanar_geometry(new ConvexGeometry(vectors));
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
// Non-indexed hull geometry with artificial closing faces removed.
|
|
613
|
+
// The convex hull includes faces that close the diagram at the lower axis
|
|
614
|
+
// limits — flat walls and diagonal closing triangles. These are artificial
|
|
615
|
+
// (they depend on how far we extend the axes) and clutter the view.
|
|
616
|
+
// We detect them via their outward-pointing face normal: closing faces have
|
|
617
|
+
// normals pointing entirely toward the negative octant (all components ≤ 0),
|
|
618
|
+
// while meaningful domain boundaries always have at least one positive
|
|
619
|
+
// normal component (pointing toward 0 eV / the elemental reference).
|
|
620
|
+
const hull_base_geometry = $derived.by(() => {
|
|
621
|
+
if (!occlusion_hull_geometry)
|
|
622
|
+
return null;
|
|
623
|
+
const src = occlusion_hull_geometry.index
|
|
624
|
+
? occlusion_hull_geometry.toNonIndexed()
|
|
625
|
+
: occlusion_hull_geometry.clone();
|
|
626
|
+
const pos = src.getAttribute(`position`);
|
|
627
|
+
const n_verts = pos.count;
|
|
628
|
+
const n_faces = n_verts / 3;
|
|
629
|
+
// Hull centroid for orienting face normals outward
|
|
630
|
+
let hx = 0, hy = 0, hz = 0;
|
|
631
|
+
for (let vert_idx = 0; vert_idx < n_verts; vert_idx++) {
|
|
632
|
+
hx += pos.getX(vert_idx);
|
|
633
|
+
hy += pos.getY(vert_idx);
|
|
634
|
+
hz += pos.getZ(vert_idx);
|
|
635
|
+
}
|
|
636
|
+
hx /= n_verts;
|
|
637
|
+
hy /= n_verts;
|
|
638
|
+
hz /= n_verts;
|
|
639
|
+
const kept = [];
|
|
640
|
+
for (let face_idx = 0; face_idx < n_faces; face_idx++) {
|
|
641
|
+
const base = face_idx * 3;
|
|
642
|
+
const va = [pos.getX(base), pos.getY(base), pos.getZ(base)];
|
|
643
|
+
const vb = [pos.getX(base + 1), pos.getY(base + 1), pos.getZ(base + 1)];
|
|
644
|
+
const vc = [pos.getX(base + 2), pos.getY(base + 2), pos.getZ(base + 2)];
|
|
645
|
+
// Face normal via cross product of two edges
|
|
646
|
+
let normal = cross_3d([vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]], [vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]]);
|
|
647
|
+
// Orient outward (away from hull centroid)
|
|
648
|
+
const dx = (va[0] + vb[0] + vc[0]) / 3 - hx;
|
|
649
|
+
const dy = (va[1] + vb[1] + vc[1]) / 3 - hy;
|
|
650
|
+
const dz = (va[2] + vb[2] + vc[2]) / 3 - hz;
|
|
651
|
+
if (normal[0] * dx + normal[1] * dy + normal[2] * dz < 0) {
|
|
652
|
+
normal = [-normal[0], -normal[1], -normal[2]];
|
|
653
|
+
}
|
|
654
|
+
// Closing faces point entirely toward negative octant (all ≤ 0).
|
|
655
|
+
// Meaningful domain faces always have at least one positive component.
|
|
656
|
+
if (normal[0] <= 0 && normal[1] <= 0 && normal[2] <= 0)
|
|
657
|
+
continue;
|
|
658
|
+
kept.push(...va, ...vb, ...vc);
|
|
659
|
+
}
|
|
660
|
+
// Re-merge coplanar faces after the filter — the closing-face removal
|
|
661
|
+
// can expose new coplanar adjacencies or leave fragments that should be
|
|
662
|
+
// merged into cleaner fan triangulations.
|
|
663
|
+
const merged = merge_coplanar_triangles(new Float32Array(kept));
|
|
664
|
+
const geom = new THREE.BufferGeometry();
|
|
665
|
+
geom.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3));
|
|
666
|
+
const colors = new Float32Array(merged.length).fill(0.965);
|
|
667
|
+
geom.setAttribute(`color`, new THREE.Float32BufferAttribute(colors, 3));
|
|
668
|
+
return geom;
|
|
669
|
+
});
|
|
670
|
+
// Per-face domain assignment (stable — only changes when geometry or domains change).
|
|
671
|
+
// Uses actual vertex centroid (mean of points_3d) for robust nearest-face matching.
|
|
672
|
+
const face_domain_map = $derived.by(() => {
|
|
673
|
+
if (!hull_base_geometry)
|
|
674
|
+
return [];
|
|
675
|
+
const pos = hull_base_geometry.getAttribute(`position`);
|
|
676
|
+
const n_faces = pos.count / 3;
|
|
677
|
+
// Domain vertex centroids in render coords (swizzled + axis stretch), matching hull_base_geometry.
|
|
678
|
+
const centroids = render_domains
|
|
679
|
+
.filter((d) => !d.is_draw_formula && d.points_3d.length > 0)
|
|
680
|
+
.map((d) => {
|
|
681
|
+
let sx = 0, sy = 0, sz = 0;
|
|
682
|
+
for (const pt of d.points_3d) {
|
|
683
|
+
const [x_val, y_val, z_val] = to_render_xyz(pt);
|
|
684
|
+
sx += x_val;
|
|
685
|
+
sy += y_val;
|
|
686
|
+
sz += z_val;
|
|
687
|
+
}
|
|
688
|
+
const n = d.points_3d.length;
|
|
689
|
+
return { formula: d.formula, cx: sx / n, cy: sy / n, cz: sz / n };
|
|
690
|
+
});
|
|
691
|
+
// Assign each face to the nearest domain centroid
|
|
692
|
+
const result = [];
|
|
693
|
+
for (let face_idx = 0; face_idx < n_faces; face_idx++) {
|
|
694
|
+
const base = face_idx * 3;
|
|
695
|
+
const fcx = (pos.getX(base) + pos.getX(base + 1) + pos.getX(base + 2)) / 3;
|
|
696
|
+
const fcy = (pos.getY(base) + pos.getY(base + 1) + pos.getY(base + 2)) / 3;
|
|
697
|
+
const fcz = (pos.getZ(base) + pos.getZ(base + 1) + pos.getZ(base + 2)) / 3;
|
|
698
|
+
let best_formula = ``;
|
|
699
|
+
let best_dist = Infinity;
|
|
700
|
+
for (const dc of centroids) {
|
|
701
|
+
const dist = (fcx - dc.cx) ** 2 + (fcy - dc.cy) ** 2 + (fcz - dc.cz) ** 2;
|
|
702
|
+
if (dist < best_dist) {
|
|
703
|
+
best_dist = dist;
|
|
704
|
+
best_formula = dc.formula;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
result.push(best_formula);
|
|
708
|
+
}
|
|
709
|
+
// Unify coplanar adjacent faces to the majority domain so that fan
|
|
710
|
+
// triangulation edges within a single hull face don't create visible
|
|
711
|
+
// color boundaries. Build adjacency via shared edge keys, group
|
|
712
|
+
// coplanar neighbors, then assign each group to its most-common domain.
|
|
713
|
+
if (n_faces > 1) {
|
|
714
|
+
const tol = 1e-3;
|
|
715
|
+
const round = (v) => Math.round(v / tol);
|
|
716
|
+
const vkey = (vert_idx) => `${round(pos.getX(vert_idx))},${round(pos.getY(vert_idx))},${round(pos.getZ(vert_idx))}`;
|
|
717
|
+
const ekey = (ka, kb) => ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
|
718
|
+
// Compute face normals
|
|
719
|
+
const normals = [];
|
|
720
|
+
for (let face_idx = 0; face_idx < n_faces; face_idx++) {
|
|
721
|
+
const base = face_idx * 3;
|
|
722
|
+
const e1 = [
|
|
723
|
+
pos.getX(base + 1) - pos.getX(base),
|
|
724
|
+
pos.getY(base + 1) - pos.getY(base),
|
|
725
|
+
pos.getZ(base + 1) - pos.getZ(base),
|
|
726
|
+
];
|
|
727
|
+
const e2 = [
|
|
728
|
+
pos.getX(base + 2) - pos.getX(base),
|
|
729
|
+
pos.getY(base + 2) - pos.getY(base),
|
|
730
|
+
pos.getZ(base + 2) - pos.getZ(base),
|
|
731
|
+
];
|
|
732
|
+
normals.push(normalize_vec3(cross_3d(e1, e2)));
|
|
733
|
+
}
|
|
734
|
+
// Build edge → face adjacency
|
|
735
|
+
const edge_faces = new SvelteMap();
|
|
736
|
+
for (let face_idx = 0; face_idx < n_faces; face_idx++) {
|
|
737
|
+
const base = face_idx * 3;
|
|
738
|
+
const keys = [vkey(base), vkey(base + 1), vkey(base + 2)];
|
|
739
|
+
for (const ek of [
|
|
740
|
+
ekey(keys[0], keys[1]),
|
|
741
|
+
ekey(keys[1], keys[2]),
|
|
742
|
+
ekey(keys[0], keys[2]),
|
|
743
|
+
]) {
|
|
744
|
+
const list = edge_faces.get(ek);
|
|
745
|
+
if (list)
|
|
746
|
+
list.push(face_idx);
|
|
747
|
+
else
|
|
748
|
+
edge_faces.set(ek, [face_idx]);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Union-find for coplanar adjacent faces
|
|
752
|
+
const parent = Array.from({ length: n_faces }, (_, idx) => idx);
|
|
753
|
+
const find = (x) => {
|
|
754
|
+
while (parent[x] !== x) {
|
|
755
|
+
parent[x] = parent[parent[x]];
|
|
756
|
+
x = parent[x];
|
|
757
|
+
}
|
|
758
|
+
return x;
|
|
759
|
+
};
|
|
760
|
+
const union = (a_idx, b_idx) => {
|
|
761
|
+
const ra = find(a_idx), rb = find(b_idx);
|
|
762
|
+
if (ra !== rb)
|
|
763
|
+
parent[ra] = rb;
|
|
764
|
+
};
|
|
765
|
+
for (const pair of edge_faces.values()) {
|
|
766
|
+
if (pair.length !== 2)
|
|
767
|
+
continue;
|
|
768
|
+
const [fa, fb] = pair;
|
|
769
|
+
const na = normals[fa], nb = normals[fb];
|
|
770
|
+
if (Math.abs(na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2]) > 1 - tol) {
|
|
771
|
+
union(fa, fb);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// Assign majority domain to each coplanar group
|
|
775
|
+
const groups = new SvelteMap();
|
|
776
|
+
for (let face_idx = 0; face_idx < n_faces; face_idx++) {
|
|
777
|
+
const root = find(face_idx);
|
|
778
|
+
const grp = groups.get(root);
|
|
779
|
+
if (grp)
|
|
780
|
+
grp.push(face_idx);
|
|
781
|
+
else
|
|
782
|
+
groups.set(root, [face_idx]);
|
|
783
|
+
}
|
|
784
|
+
for (const members of groups.values()) {
|
|
785
|
+
if (members.length < 2)
|
|
786
|
+
continue;
|
|
787
|
+
// Find most common domain in this group
|
|
788
|
+
const counts = new SvelteMap();
|
|
789
|
+
for (const member_idx of members) {
|
|
790
|
+
counts.set(result[member_idx], (counts.get(result[member_idx]) ?? 0) + 1);
|
|
791
|
+
}
|
|
792
|
+
let majority = result[members[0]];
|
|
793
|
+
let max_count = 0;
|
|
794
|
+
for (const [formula, count] of counts) {
|
|
795
|
+
if (count > max_count) {
|
|
796
|
+
max_count = count;
|
|
797
|
+
majority = formula;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (const member_idx of members)
|
|
801
|
+
result[member_idx] = majority;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return result;
|
|
805
|
+
});
|
|
806
|
+
// Reactive color fill: creates a cloned geometry with vertex colors applied.
|
|
807
|
+
// Only runs when color_mode or domain_colors change — no mutation of hull_base_geometry.
|
|
808
|
+
const colored_hull_geometry = $derived.by(() => {
|
|
809
|
+
const mapping = face_domain_map;
|
|
810
|
+
if (!hull_base_geometry || mapping.length === 0)
|
|
811
|
+
return hull_base_geometry;
|
|
812
|
+
const geom = hull_base_geometry.clone();
|
|
813
|
+
const color_attr = geom.getAttribute(`color`);
|
|
814
|
+
const use_colors = color_mode !== `none` && domain_colors.size > 0;
|
|
815
|
+
const fb = use_colors
|
|
816
|
+
? [0.91, 0.91, 0.91] // #e8e8e8
|
|
817
|
+
: [0.965, 0.965, 0.965]; // #f6f6f6
|
|
818
|
+
// Cache parsed RGB per formula to avoid redundant THREE.Color allocations
|
|
819
|
+
const rgb_cache = new SvelteMap();
|
|
820
|
+
for (const [formula, hex] of domain_colors) {
|
|
821
|
+
const clr = new THREE.Color(hex);
|
|
822
|
+
rgb_cache.set(formula, [clr.r, clr.g, clr.b]);
|
|
823
|
+
}
|
|
824
|
+
for (let face_idx = 0; face_idx < mapping.length; face_idx++) {
|
|
825
|
+
const rgb = use_colors ? rgb_cache.get(mapping[face_idx]) : null;
|
|
826
|
+
const [red, green, blue] = rgb ?? fb;
|
|
827
|
+
const base = face_idx * 3;
|
|
828
|
+
for (let vert_idx = 0; vert_idx < 3; vert_idx++) {
|
|
829
|
+
color_attr.setXYZ(base + vert_idx, red, green, blue);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
color_attr.needsUpdate = true;
|
|
833
|
+
return geom;
|
|
834
|
+
});
|
|
835
|
+
$effect(() => {
|
|
836
|
+
const geom = hull_base_geometry;
|
|
837
|
+
return () => dispose_geometry(geom);
|
|
838
|
+
});
|
|
839
|
+
$effect(() => {
|
|
840
|
+
const geom = colored_hull_geometry;
|
|
841
|
+
// Don't dispose if it's the same object as hull_base_geometry (no clone was made)
|
|
842
|
+
if (geom && geom !== hull_base_geometry)
|
|
843
|
+
return () => dispose_geometry(geom);
|
|
844
|
+
});
|
|
845
|
+
// Domains on the outer surface: annotation point NOT strictly inside the hull.
|
|
846
|
+
// Interior domains are hidden behind the surface and shouldn't show labels.
|
|
847
|
+
const surface_formulas = $derived.by(() => {
|
|
848
|
+
const on_surface = new SvelteSet();
|
|
849
|
+
if (!occlusion_hull_geometry) {
|
|
850
|
+
for (const domain of render_domains)
|
|
851
|
+
on_surface.add(domain.formula);
|
|
852
|
+
return on_surface;
|
|
853
|
+
}
|
|
854
|
+
// Raycast from each domain's centroid outward -- if it hits the hull,
|
|
855
|
+
// the centroid is inside (interior domain). Use multiple ray directions
|
|
856
|
+
// and count: if most hit, the point is interior.
|
|
857
|
+
const raycaster = new THREE.Raycaster();
|
|
858
|
+
const hull_mesh = new THREE.Mesh(occlusion_hull_geometry);
|
|
859
|
+
const directions = [
|
|
860
|
+
new THREE.Vector3(1, 0, 0),
|
|
861
|
+
new THREE.Vector3(0, 1, 0),
|
|
862
|
+
new THREE.Vector3(0, 0, 1),
|
|
863
|
+
new THREE.Vector3(-1, 0, 0),
|
|
864
|
+
new THREE.Vector3(0, -1, 0),
|
|
865
|
+
new THREE.Vector3(0, 0, -1),
|
|
866
|
+
];
|
|
867
|
+
for (const domain of render_domains) {
|
|
868
|
+
if (domain.is_draw_formula) {
|
|
869
|
+
on_surface.add(domain.formula);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const origin = to_vec3(domain.ann_loc);
|
|
873
|
+
// Count how many rays hit the hull from the centroid
|
|
874
|
+
let hits = 0;
|
|
875
|
+
for (const dir of directions) {
|
|
876
|
+
raycaster.set(origin, dir);
|
|
877
|
+
if (raycaster.intersectObject(hull_mesh).length > 0)
|
|
878
|
+
hits++;
|
|
879
|
+
}
|
|
880
|
+
// If fewer than 4 of 6 rays hit, centroid is on or near the surface
|
|
881
|
+
if (hits < 4)
|
|
882
|
+
on_surface.add(domain.formula);
|
|
883
|
+
}
|
|
884
|
+
return on_surface;
|
|
885
|
+
});
|
|
886
|
+
// Deduplicate 3D points within tolerance (reuses compute.ts dedup_points)
|
|
887
|
+
function dedup_3d(pts, tol = 1e-4) {
|
|
888
|
+
return dedup_points(pts, tol).unique;
|
|
889
|
+
}
|
|
890
|
+
const controls_series = $derived([
|
|
891
|
+
{
|
|
892
|
+
x: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[1])),
|
|
893
|
+
y: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[2])),
|
|
894
|
+
z: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[0])),
|
|
895
|
+
label: `domains`,
|
|
896
|
+
},
|
|
897
|
+
]);
|
|
898
|
+
// Build formula overlay edge geometries (per formula, colored) using crease edges
|
|
899
|
+
const formula_edge_data = $derived.by(() => {
|
|
900
|
+
if (!draw_formula_lines || formulas_to_draw.length === 0)
|
|
901
|
+
return [];
|
|
902
|
+
const result = [];
|
|
903
|
+
for (const domain of render_domains) {
|
|
904
|
+
if (!domain.is_draw_formula)
|
|
905
|
+
continue;
|
|
906
|
+
const color_idx = formulas_to_draw.indexOf(domain.formula) %
|
|
907
|
+
formula_colors.length;
|
|
908
|
+
const swizzled = domain.points_3d.map((point) => to_render_xyz(point));
|
|
909
|
+
const positions = [];
|
|
910
|
+
for (const [pa, pb] of get_domain_edges(swizzled)) {
|
|
911
|
+
positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2]);
|
|
912
|
+
}
|
|
913
|
+
const geom = new THREE.BufferGeometry();
|
|
914
|
+
geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
|
|
915
|
+
result.push({ geometry: geom, color: formula_colors[color_idx] });
|
|
916
|
+
}
|
|
917
|
+
return result;
|
|
918
|
+
});
|
|
919
|
+
// Build formula overlay mesh geometries (convex hull surface)
|
|
920
|
+
const formula_mesh_data = $derived.by(() => {
|
|
921
|
+
const result = [];
|
|
922
|
+
if (!draw_formula_meshes)
|
|
923
|
+
return result;
|
|
924
|
+
for (const domain of render_domains) {
|
|
925
|
+
if (!domain.is_draw_formula || domain.points_3d.length < 4)
|
|
926
|
+
continue;
|
|
927
|
+
const color_idx = formulas_to_draw.indexOf(domain.formula) %
|
|
928
|
+
formula_colors.length;
|
|
929
|
+
const unique = dedup_3d(domain.points_3d);
|
|
930
|
+
if (unique.length < 4)
|
|
931
|
+
continue;
|
|
932
|
+
const vectors = unique.map((pt) => to_vec3(pt));
|
|
933
|
+
try {
|
|
934
|
+
const geom = merge_coplanar_geometry(new ConvexGeometry(vectors));
|
|
935
|
+
result.push({ geometry: geom, color: formula_colors[color_idx] });
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
// Degenerate hull, skip
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return result;
|
|
942
|
+
});
|
|
943
|
+
function get_touches_limits(points_3d, lims) {
|
|
944
|
+
const limit_tol = 1e-3;
|
|
945
|
+
const touches_limits = [];
|
|
946
|
+
for (let axis_idx = 0; axis_idx < Math.min(plot_elements.length, lims.length); axis_idx++) {
|
|
947
|
+
const [axis_min, axis_max] = lims[axis_idx];
|
|
948
|
+
const axis_name = plot_elements[axis_idx] ?? `axis_${axis_idx}`;
|
|
949
|
+
const touches_min = points_3d.some((point) => Math.abs(point[axis_idx] - axis_min) < limit_tol);
|
|
950
|
+
const touches_max = points_3d.some((point) => Math.abs(point[axis_idx] - axis_max) < limit_tol);
|
|
951
|
+
if (touches_min)
|
|
952
|
+
touches_limits.push(`${axis_name} lower bound`);
|
|
953
|
+
if (touches_max)
|
|
954
|
+
touches_limits.push(`${axis_name} upper bound`);
|
|
955
|
+
}
|
|
956
|
+
return touches_limits;
|
|
957
|
+
}
|
|
958
|
+
// Post-process ConvexGeometry to merge coplanar triangles, eliminating
|
|
959
|
+
// internal diagonal edges across flat faces of the convex hull.
|
|
960
|
+
function merge_coplanar_geometry(geom) {
|
|
961
|
+
const non_indexed = geom.index ? geom.toNonIndexed() : geom;
|
|
962
|
+
const pos = non_indexed.getAttribute(`position`);
|
|
963
|
+
const merged = merge_coplanar_triangles(pos.array);
|
|
964
|
+
const result = new THREE.BufferGeometry();
|
|
965
|
+
result.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3));
|
|
966
|
+
result.computeVertexNormals();
|
|
967
|
+
// Dispose intermediate geometry from toNonIndexed() (avoid double-dispose if same object)
|
|
968
|
+
if (non_indexed !== geom)
|
|
969
|
+
non_indexed.dispose();
|
|
970
|
+
// Callers always pass a freshly created ConvexGeometry, so we own it
|
|
971
|
+
geom.dispose();
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
function create_hover_geometry(points_3d) {
|
|
975
|
+
const unique_points = dedup_3d(points_3d);
|
|
976
|
+
if (unique_points.length < 3)
|
|
977
|
+
return null;
|
|
978
|
+
// For exactly 3 unique points (planar/degenerate domain), create a triangle
|
|
979
|
+
// geometry directly since ConvexGeometry requires 4+ points for a 3D hull
|
|
980
|
+
if (unique_points.length === 3) {
|
|
981
|
+
const geom = new THREE.BufferGeometry();
|
|
982
|
+
const vectors = unique_points.map((pt) => to_vec3(pt));
|
|
983
|
+
const verts = new Float32Array(vectors.flatMap((v) => [v.x, v.y, v.z]));
|
|
984
|
+
geom.setAttribute(`position`, new THREE.Float32BufferAttribute(verts, 3));
|
|
985
|
+
geom.setIndex([0, 1, 2, 2, 1, 0]); // both winding orders for double-sided pick
|
|
986
|
+
geom.computeVertexNormals();
|
|
987
|
+
return { geometry: geom, n_vertices: 3 };
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
return {
|
|
991
|
+
geometry: merge_coplanar_geometry(new ConvexGeometry(unique_points.map((point) => to_vec3(point)))),
|
|
992
|
+
n_vertices: unique_points.length,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// Domain adjacency: two domains are neighbors if they share any vertex (within tolerance)
|
|
1000
|
+
const domain_neighbors = $derived.by(() => {
|
|
1001
|
+
const tol = 1e-4;
|
|
1002
|
+
const vertex_owners = new SvelteMap();
|
|
1003
|
+
for (const domain of render_domains) {
|
|
1004
|
+
for (const pt of domain.points_3d) {
|
|
1005
|
+
const key = pt.map((val) => (Math.round(val / tol) * tol).toFixed(4)).join(`,`);
|
|
1006
|
+
const owners = vertex_owners.get(key);
|
|
1007
|
+
if (owners) {
|
|
1008
|
+
if (!owners.includes(domain.formula))
|
|
1009
|
+
owners.push(domain.formula);
|
|
1010
|
+
}
|
|
1011
|
+
else
|
|
1012
|
+
vertex_owners.set(key, [domain.formula]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const neighbors = new SvelteMap();
|
|
1016
|
+
for (const domain of render_domains) {
|
|
1017
|
+
neighbors.set(domain.formula, new SvelteSet());
|
|
1018
|
+
}
|
|
1019
|
+
for (const owners of vertex_owners.values()) {
|
|
1020
|
+
if (owners.length < 2)
|
|
1021
|
+
continue;
|
|
1022
|
+
for (let idx = 0; idx < owners.length; idx++) {
|
|
1023
|
+
for (let jdx = idx + 1; jdx < owners.length; jdx++) {
|
|
1024
|
+
neighbors.get(owners[idx])?.add(owners[jdx]);
|
|
1025
|
+
neighbors.get(owners[jdx])?.add(owners[idx]);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const result = new SvelteMap();
|
|
1030
|
+
for (const [formula, set] of neighbors)
|
|
1031
|
+
result.set(formula, [...set].sort());
|
|
1032
|
+
return result;
|
|
1033
|
+
});
|
|
1034
|
+
const hover_mesh_data = $derived.by(() => {
|
|
1035
|
+
if (!diagram_data)
|
|
1036
|
+
return [];
|
|
1037
|
+
const result = [];
|
|
1038
|
+
const lims = diagram_data.lims;
|
|
1039
|
+
const energy_stats_by_formula = entry_energy_stats_by_formula;
|
|
1040
|
+
for (const domain of render_domains) {
|
|
1041
|
+
if (domain.points_3d.length < 3)
|
|
1042
|
+
continue;
|
|
1043
|
+
const hover_geometry = create_hover_geometry(domain.points_3d);
|
|
1044
|
+
if (!hover_geometry)
|
|
1045
|
+
continue;
|
|
1046
|
+
const { geometry, n_vertices } = hover_geometry;
|
|
1047
|
+
const swizzled_points = domain.points_3d.map((point) => to_render_xyz(point));
|
|
1048
|
+
const edge_count = get_domain_edges(swizzled_points).length;
|
|
1049
|
+
const axis_ranges = build_axis_ranges(domain.points_3d, plot_elements);
|
|
1050
|
+
const touches_limits = get_touches_limits(domain.points_3d, lims);
|
|
1051
|
+
const energy_stats = energy_stats_by_formula.get(domain.formula) ?? {
|
|
1052
|
+
matching_entry_count: 0,
|
|
1053
|
+
min_energy_per_atom: null,
|
|
1054
|
+
max_energy_per_atom: null,
|
|
1055
|
+
};
|
|
1056
|
+
const info = {
|
|
1057
|
+
formula: domain.formula,
|
|
1058
|
+
view: `3d`,
|
|
1059
|
+
n_vertices,
|
|
1060
|
+
n_edges: edge_count,
|
|
1061
|
+
n_points: domain.points_3d.length,
|
|
1062
|
+
ann_loc: domain.ann_loc,
|
|
1063
|
+
axis_ranges,
|
|
1064
|
+
touches_limits,
|
|
1065
|
+
is_elemental: all_entry_elements.includes(domain.formula),
|
|
1066
|
+
is_draw_formula: domain.is_draw_formula,
|
|
1067
|
+
matching_entry_count: energy_stats.matching_entry_count,
|
|
1068
|
+
min_energy_per_atom: energy_stats.min_energy_per_atom,
|
|
1069
|
+
max_energy_per_atom: energy_stats.max_energy_per_atom,
|
|
1070
|
+
neighbors: domain_neighbors.get(domain.formula) ?? [],
|
|
1071
|
+
};
|
|
1072
|
+
result.push({
|
|
1073
|
+
formula: domain.formula,
|
|
1074
|
+
geometry,
|
|
1075
|
+
info,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return result;
|
|
1079
|
+
});
|
|
1080
|
+
function dispose_geometry(geometry) {
|
|
1081
|
+
if (!geometry)
|
|
1082
|
+
return;
|
|
1083
|
+
geometry.dispose();
|
|
1084
|
+
}
|
|
1085
|
+
function dispose_geometries(geometries) {
|
|
1086
|
+
for (const geometry of geometries)
|
|
1087
|
+
dispose_geometry(geometry);
|
|
1088
|
+
}
|
|
1089
|
+
$effect(() => {
|
|
1090
|
+
const geometry = edge_geometry;
|
|
1091
|
+
return () => dispose_geometry(geometry);
|
|
1092
|
+
});
|
|
1093
|
+
$effect(() => {
|
|
1094
|
+
const geometry = occlusion_hull_geometry;
|
|
1095
|
+
return () => dispose_geometry(geometry);
|
|
1096
|
+
});
|
|
1097
|
+
$effect(() => {
|
|
1098
|
+
const geometry = bounding_box_geometry;
|
|
1099
|
+
return () => dispose_geometry(geometry);
|
|
1100
|
+
});
|
|
1101
|
+
$effect(() => {
|
|
1102
|
+
const geometries = formula_edge_data.map((data) => data.geometry);
|
|
1103
|
+
return () => dispose_geometries(geometries);
|
|
1104
|
+
});
|
|
1105
|
+
$effect(() => {
|
|
1106
|
+
const geometries = formula_mesh_data.map((data) => data.geometry);
|
|
1107
|
+
return () => dispose_geometries(geometries);
|
|
1108
|
+
});
|
|
1109
|
+
$effect(() => {
|
|
1110
|
+
const geometries = hover_mesh_data.map((data) => data.geometry);
|
|
1111
|
+
return () => dispose_geometries(geometries);
|
|
1112
|
+
});
|
|
1113
|
+
// === Grid, axes, ticks (matching ScatterPlot3D style) ===
|
|
1114
|
+
// Bounding box of all data points in DATA coordinates (before swizzle)
|
|
1115
|
+
const raw_data_bbox = $derived.by(() => {
|
|
1116
|
+
const pts = render_domains.flatMap((d) => d.points_3d);
|
|
1117
|
+
if (pts.length === 0)
|
|
1118
|
+
return { mins: [0, 0, 0], maxs: [1, 1, 1] };
|
|
1119
|
+
const mins = [Infinity, Infinity, Infinity];
|
|
1120
|
+
const maxs = [-Infinity, -Infinity, -Infinity];
|
|
1121
|
+
for (const pt of pts) {
|
|
1122
|
+
for (let dim = 0; dim < 3; dim++) {
|
|
1123
|
+
if (pt[dim] < mins[dim])
|
|
1124
|
+
mins[dim] = pt[dim];
|
|
1125
|
+
if (pt[dim] > maxs[dim])
|
|
1126
|
+
maxs[dim] = pt[dim];
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return { mins, maxs };
|
|
1130
|
+
});
|
|
1131
|
+
// Axis range controls are in swizzled axis order:
|
|
1132
|
+
// x-axis control -> data axis 1, y-axis control -> data axis 2, z-axis control -> data axis 0
|
|
1133
|
+
const data_bbox = $derived.by(() => {
|
|
1134
|
+
const mins = [...raw_data_bbox.mins];
|
|
1135
|
+
const maxs = [...raw_data_bbox.maxs];
|
|
1136
|
+
const range_by_data_axis = [
|
|
1137
|
+
z_axis.range,
|
|
1138
|
+
x_axis.range,
|
|
1139
|
+
y_axis.range,
|
|
1140
|
+
];
|
|
1141
|
+
for (let axis_idx = 0; axis_idx < 3; axis_idx++) {
|
|
1142
|
+
const range = range_by_data_axis[axis_idx];
|
|
1143
|
+
if (!range)
|
|
1144
|
+
continue;
|
|
1145
|
+
const [range_min, range_max] = range;
|
|
1146
|
+
if (range_min !== null)
|
|
1147
|
+
mins[axis_idx] = range_min;
|
|
1148
|
+
if (range_max !== null)
|
|
1149
|
+
maxs[axis_idx] = range_max;
|
|
1150
|
+
}
|
|
1151
|
+
return { mins, maxs };
|
|
1152
|
+
});
|
|
1153
|
+
// Generate nice tick values for each data axis using D3
|
|
1154
|
+
function gen_ticks(min_val, max_val, count = 5) {
|
|
1155
|
+
if (!isFinite(min_val) || !isFinite(max_val) || min_val === max_val) {
|
|
1156
|
+
return [min_val];
|
|
1157
|
+
}
|
|
1158
|
+
return scaleLinear().domain([min_val, max_val]).nice().ticks(count);
|
|
1159
|
+
}
|
|
1160
|
+
// Ticks in DATA coordinates for each of the 3 data axes
|
|
1161
|
+
const data_ticks = $derived([
|
|
1162
|
+
gen_ticks(data_bbox.mins[0], data_bbox.maxs[0]),
|
|
1163
|
+
gen_ticks(data_bbox.mins[1], data_bbox.maxs[1]),
|
|
1164
|
+
gen_ticks(data_bbox.mins[2], data_bbox.maxs[2]),
|
|
1165
|
+
]);
|
|
1166
|
+
// Niced ranges (from ticks) padded so the grid extends beyond the diagram.
|
|
1167
|
+
// For horizontal axes (0,1): pad both sides.
|
|
1168
|
+
// For vertical axis (2): use actual data range and round min down to an integer.
|
|
1169
|
+
const niced_range = $derived.by(() => {
|
|
1170
|
+
return [0, 1, 2].map((axis) => {
|
|
1171
|
+
const ticks = data_ticks[axis];
|
|
1172
|
+
const lo = ticks[0];
|
|
1173
|
+
const hi = ticks.at(-1) ?? lo;
|
|
1174
|
+
const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1;
|
|
1175
|
+
if (axis === 2) {
|
|
1176
|
+
const min_data = data_bbox.mins[2];
|
|
1177
|
+
const max_data = data_bbox.maxs[2];
|
|
1178
|
+
return [Math.floor(min_data), max_data];
|
|
1179
|
+
}
|
|
1180
|
+
return [lo - step, hi + step];
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
// Helper to create a line geometry from two Vec3 arrays
|
|
1184
|
+
function make_line_geom(start, end) {
|
|
1185
|
+
const geom = new THREE.BufferGeometry();
|
|
1186
|
+
geom.setAttribute(`position`, new THREE.BufferAttribute(new Float32Array([...start, ...end]), 3));
|
|
1187
|
+
return geom;
|
|
1188
|
+
}
|
|
1189
|
+
// Swizzle a data-coord triple to Three.js coords
|
|
1190
|
+
function swiz(d0, d1, d2) {
|
|
1191
|
+
const [scale_x, scale_y, scale_z] = render_axis_scale;
|
|
1192
|
+
return [d1 * scale_x, d2 * scale_y, d0 * scale_z]; // data[0]→Z, data[1]→X, data[2]→Y
|
|
1193
|
+
}
|
|
1194
|
+
const axis_colors = [`#e74c3c`, `#2ecc71`, `#3498db`];
|
|
1195
|
+
function chem_axis_label(data_axis) {
|
|
1196
|
+
return formal_chempots
|
|
1197
|
+
? `\u0394\u03BC(${plot_elements[data_axis]}) (eV)`
|
|
1198
|
+
: `\u03BC(${plot_elements[data_axis]}) (eV)`;
|
|
1199
|
+
}
|
|
1200
|
+
// Proportional offsets for tick marks and labels, scaled to data extent
|
|
1201
|
+
const tick_size = $derived(data_extent * 0.015);
|
|
1202
|
+
const tick_label_dist = $derived(data_extent * 0.04);
|
|
1203
|
+
const axis_label_dist = $derived(data_extent * 0.08);
|
|
1204
|
+
// Place axis label just past the outer end of the axis (the end closer to 0).
|
|
1205
|
+
// In isometric 3D, the end near 0 projects outward at the front edge of the
|
|
1206
|
+
// bounding box, while the negative end projects inward toward the center.
|
|
1207
|
+
function outer_end(range) {
|
|
1208
|
+
return Math.abs(range[0]) <= Math.abs(range[1]) ? range[0] : range[1];
|
|
1209
|
+
}
|
|
1210
|
+
// Direction from range center toward outer end (to extend the label beyond the grid)
|
|
1211
|
+
function outer_dir(range) {
|
|
1212
|
+
const end = outer_end(range);
|
|
1213
|
+
const mid = (range[0] + range[1]) / 2;
|
|
1214
|
+
return end >= mid ? 1 : -1;
|
|
1215
|
+
}
|
|
1216
|
+
// Grid/axis configuration for each data axis.
|
|
1217
|
+
// Axes, ticks, and labels are placed on the backside (far from camera)
|
|
1218
|
+
// matching ScatterPlot3DScene's dynamic backside tracking pattern.
|
|
1219
|
+
const grid_config = $derived.by(() => {
|
|
1220
|
+
const [r0, r1, r2] = niced_range;
|
|
1221
|
+
return [0, 1, 2].map((axis) => {
|
|
1222
|
+
const ticks = data_ticks[axis];
|
|
1223
|
+
const color = axis_colors[axis];
|
|
1224
|
+
const label = axis === 0
|
|
1225
|
+
? (z_axis.label || chem_axis_label(0))
|
|
1226
|
+
: axis === 1
|
|
1227
|
+
? (x_axis.label || chem_axis_label(1))
|
|
1228
|
+
: (y_axis.label || chem_axis_label(2));
|
|
1229
|
+
const tick_geoms = [];
|
|
1230
|
+
const grid_geoms = [];
|
|
1231
|
+
const tick_labels = [];
|
|
1232
|
+
let line_geom;
|
|
1233
|
+
let label_pos;
|
|
1234
|
+
if (axis === 0) {
|
|
1235
|
+
// Data axis 0 (Three.js Z, depth): axis at backside d1 and d2
|
|
1236
|
+
const ls = swiz(r0[0], back[1], back[2]);
|
|
1237
|
+
const le = swiz(r0[1], back[1], back[2]);
|
|
1238
|
+
line_geom = make_line_geom(ls, le);
|
|
1239
|
+
// Axis label past the outer end of the axis (near 0, projects outward)
|
|
1240
|
+
label_pos = swiz(outer_end(r0) + outer_dir(r0) * axis_label_dist, back[1] + out_x * tick_label_dist * 0.5, back[2] + out_y * tick_label_dist);
|
|
1241
|
+
for (const val of ticks) {
|
|
1242
|
+
tick_geoms.push(make_line_geom(swiz(val, back[1], back[2]), swiz(val, back[1], back[2] + out_y * tick_size)));
|
|
1243
|
+
grid_geoms.push(make_line_geom(swiz(val, r1[0], back[2]), swiz(val, r1[1], back[2])));
|
|
1244
|
+
grid_geoms.push(make_line_geom(swiz(val, back[1], r2[0]), swiz(val, back[1], r2[1])));
|
|
1245
|
+
tick_labels.push({
|
|
1246
|
+
pos: swiz(val, back[1] + out_x * tick_label_dist * 0.5, back[2] + out_y * tick_label_dist),
|
|
1247
|
+
text: format_num(val, `.3~g`),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
else if (axis === 1) {
|
|
1252
|
+
// Data axis 1 (Three.js X, horizontal): axis at backside d0 and d2
|
|
1253
|
+
const ls = swiz(back[0], r1[0], back[2]);
|
|
1254
|
+
const le = swiz(back[0], r1[1], back[2]);
|
|
1255
|
+
line_geom = make_line_geom(ls, le);
|
|
1256
|
+
label_pos = swiz(back[0], outer_end(r1) + outer_dir(r1) * axis_label_dist, back[2] + out_y * tick_label_dist);
|
|
1257
|
+
for (const val of ticks) {
|
|
1258
|
+
tick_geoms.push(make_line_geom(swiz(back[0], val, back[2]), swiz(back[0], val, back[2] + out_y * tick_size)));
|
|
1259
|
+
grid_geoms.push(make_line_geom(swiz(r0[0], val, back[2]), swiz(r0[1], val, back[2])));
|
|
1260
|
+
grid_geoms.push(make_line_geom(swiz(back[0], val, r2[0]), swiz(back[0], val, r2[1])));
|
|
1261
|
+
tick_labels.push({
|
|
1262
|
+
pos: swiz(back[0], val, back[2] + out_y * tick_label_dist),
|
|
1263
|
+
text: format_num(val, `.3~g`),
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
// Data axis 2 (Three.js Y, vertical): axis at backside d0 and d1
|
|
1269
|
+
const ls = swiz(back[0], back[1], r2[0]);
|
|
1270
|
+
const le = swiz(back[0], back[1], r2[1]);
|
|
1271
|
+
line_geom = make_line_geom(ls, le);
|
|
1272
|
+
label_pos = swiz(back[0], back[1] + out_x * tick_label_dist, outer_end(r2) + outer_dir(r2) * axis_label_dist);
|
|
1273
|
+
for (const val of ticks) {
|
|
1274
|
+
tick_geoms.push(make_line_geom(swiz(back[0], back[1], val), swiz(back[0], back[1] + out_x * tick_size, val)));
|
|
1275
|
+
grid_geoms.push(make_line_geom(swiz(r0[0], back[1], val), swiz(r0[1], back[1], val)));
|
|
1276
|
+
grid_geoms.push(make_line_geom(swiz(back[0], r1[0], val), swiz(back[0], r1[1], val)));
|
|
1277
|
+
tick_labels.push({
|
|
1278
|
+
pos: swiz(back[0], back[1] + out_x * tick_label_dist, val),
|
|
1279
|
+
text: format_num(val, `.3~g`),
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
axis,
|
|
1285
|
+
color,
|
|
1286
|
+
label,
|
|
1287
|
+
line_geom,
|
|
1288
|
+
tick_geoms,
|
|
1289
|
+
grid_geoms,
|
|
1290
|
+
tick_labels,
|
|
1291
|
+
label_pos,
|
|
1292
|
+
};
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
// Update backside positions when camera crosses axis planes.
|
|
1296
|
+
// Only updates when sign changes to avoid triggering geometry recreation every frame.
|
|
1297
|
+
function update_backside() {
|
|
1298
|
+
const cam = orbit_controls_ref?.object?.position;
|
|
1299
|
+
if (!cam)
|
|
1300
|
+
return;
|
|
1301
|
+
const [r0, r1, r2] = niced_range;
|
|
1302
|
+
// swiz: data[0]→Z, data[1]→X, data[2]→Y
|
|
1303
|
+
const new_back_0 = cam.z > data_center.z ? r0[0] : r0[1];
|
|
1304
|
+
const new_back_1 = cam.x > data_center.x ? r1[0] : r1[1];
|
|
1305
|
+
const new_back_2 = cam.y > data_center.y ? r2[0] : r2[1];
|
|
1306
|
+
if (back[0] !== new_back_0 || back[1] !== new_back_1 || back[2] !== new_back_2) {
|
|
1307
|
+
back = [new_back_0, new_back_1, new_back_2];
|
|
1308
|
+
out_x = cam.x > data_center.x ? -1 : 1;
|
|
1309
|
+
out_y = cam.y > data_center.y ? -1 : 1;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function store_camera_view_state() {
|
|
1313
|
+
// Prime framing baseline on first user interaction so the next geometry
|
|
1314
|
+
// change can preserve zoom/center immediately (not only from second change).
|
|
1315
|
+
if (last_data_center === null) {
|
|
1316
|
+
last_data_center = [data_center.x, data_center.y, data_center.z];
|
|
1317
|
+
}
|
|
1318
|
+
if (last_data_extent === null) {
|
|
1319
|
+
last_data_extent = data_extent;
|
|
1320
|
+
}
|
|
1321
|
+
const controls = orbit_controls_ref;
|
|
1322
|
+
const controls_camera = controls?.object;
|
|
1323
|
+
if (controls_camera) {
|
|
1324
|
+
camera_position_override = [
|
|
1325
|
+
controls_camera.position.x,
|
|
1326
|
+
controls_camera.position.y,
|
|
1327
|
+
controls_camera.position.z,
|
|
1328
|
+
];
|
|
1329
|
+
if (controls_camera instanceof THREE.OrthographicCamera) {
|
|
1330
|
+
orthographic_zoom_override = controls_camera.zoom;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
const controls_target = controls?.target;
|
|
1334
|
+
if (controls_target) {
|
|
1335
|
+
camera_target_override = [
|
|
1336
|
+
controls_target.x,
|
|
1337
|
+
controls_target.y,
|
|
1338
|
+
controls_target.z,
|
|
1339
|
+
];
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
// Preserve user framing across temperature-driven geometry changes:
|
|
1343
|
+
// shift camera/target with domain center and keep orthographic zoom relative to extent.
|
|
1344
|
+
$effect(() => {
|
|
1345
|
+
if (camera_position_override && camera_target_override && last_data_center) {
|
|
1346
|
+
const [last_x, last_y, last_z] = last_data_center;
|
|
1347
|
+
const delta_x = data_center.x - last_x;
|
|
1348
|
+
const delta_y = data_center.y - last_y;
|
|
1349
|
+
const delta_z = data_center.z - last_z;
|
|
1350
|
+
if (delta_x !== 0 || delta_y !== 0 || delta_z !== 0) {
|
|
1351
|
+
camera_position_override = [
|
|
1352
|
+
camera_position_override[0] + delta_x,
|
|
1353
|
+
camera_position_override[1] + delta_y,
|
|
1354
|
+
camera_position_override[2] + delta_z,
|
|
1355
|
+
];
|
|
1356
|
+
camera_target_override = [
|
|
1357
|
+
camera_target_override[0] + delta_x,
|
|
1358
|
+
camera_target_override[1] + delta_y,
|
|
1359
|
+
camera_target_override[2] + delta_z,
|
|
1360
|
+
];
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (orthographic_zoom_override !== null &&
|
|
1364
|
+
last_data_extent !== null &&
|
|
1365
|
+
last_data_extent > 0 &&
|
|
1366
|
+
data_extent > 0) {
|
|
1367
|
+
orthographic_zoom_override *= last_data_extent / data_extent;
|
|
1368
|
+
}
|
|
1369
|
+
last_data_center = [data_center.x, data_center.y, data_center.z];
|
|
1370
|
+
last_data_extent = data_extent;
|
|
1371
|
+
});
|
|
1372
|
+
$effect(() => {
|
|
1373
|
+
const controls = orbit_controls_ref;
|
|
1374
|
+
if (!controls)
|
|
1375
|
+
return;
|
|
1376
|
+
const on_controls_change = () => {
|
|
1377
|
+
update_backside();
|
|
1378
|
+
store_camera_view_state();
|
|
1379
|
+
};
|
|
1380
|
+
controls.addEventListener(`change`, on_controls_change);
|
|
1381
|
+
untrack(() => update_backside());
|
|
1382
|
+
controls.update();
|
|
1383
|
+
return () => controls.removeEventListener(`change`, on_controls_change);
|
|
1384
|
+
});
|
|
1385
|
+
$effect(() => {
|
|
1386
|
+
set_fullscreen_bg(wrapper, fullscreen, `--chempot-3d-bg-fullscreen`);
|
|
1387
|
+
});
|
|
1388
|
+
$effect(() => {
|
|
1389
|
+
const grid_geometries = grid_config;
|
|
1390
|
+
return () => {
|
|
1391
|
+
for (const grid_item of grid_geometries) {
|
|
1392
|
+
dispose_geometry(grid_item.line_geom);
|
|
1393
|
+
for (const tick_geometry of grid_item.tick_geoms) {
|
|
1394
|
+
dispose_geometry(tick_geometry);
|
|
1395
|
+
}
|
|
1396
|
+
for (const line_geometry of grid_item.grid_geoms) {
|
|
1397
|
+
dispose_geometry(line_geometry);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
});
|
|
1402
|
+
const projection_planes = $derived.by(() => {
|
|
1403
|
+
const projections = display.projections;
|
|
1404
|
+
if (!projections)
|
|
1405
|
+
return [];
|
|
1406
|
+
const [r0, r1, r2] = niced_range;
|
|
1407
|
+
const s0 = (r0[1] - r0[0]) * (display.projection_scale ?? 0.5);
|
|
1408
|
+
const s1 = (r1[1] - r1[0]) * (display.projection_scale ?? 0.5);
|
|
1409
|
+
const s2 = (r2[1] - r2[0]) * (display.projection_scale ?? 0.5);
|
|
1410
|
+
const planes = [];
|
|
1411
|
+
if (projections.xy) {
|
|
1412
|
+
planes.push({
|
|
1413
|
+
key: `xy`,
|
|
1414
|
+
pos: swiz((r0[0] + r0[1]) / 2, (r1[0] + r1[1]) / 2, back[2]),
|
|
1415
|
+
rot: [-Math.PI / 2, 0, 0],
|
|
1416
|
+
size: [s1, s0],
|
|
1417
|
+
color: `#5dade2`,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
if (projections.xz) {
|
|
1421
|
+
planes.push({
|
|
1422
|
+
key: `xz`,
|
|
1423
|
+
pos: swiz((r0[0] + r0[1]) / 2, back[1], (r2[0] + r2[1]) / 2),
|
|
1424
|
+
rot: [0, Math.PI / 2, 0],
|
|
1425
|
+
size: [s0, s2],
|
|
1426
|
+
color: `#58d68d`,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
if (projections.yz) {
|
|
1430
|
+
planes.push({
|
|
1431
|
+
key: `yz`,
|
|
1432
|
+
pos: swiz(back[0], (r1[0] + r1[1]) / 2, (r2[0] + r2[1]) / 2),
|
|
1433
|
+
rot: [0, 0, 0],
|
|
1434
|
+
size: [s1, s2],
|
|
1435
|
+
color: `#f5b041`,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return planes;
|
|
1439
|
+
});
|
|
1440
|
+
const bounding_box_geometry = $derived.by(() => {
|
|
1441
|
+
const [r0, r1, r2] = niced_range;
|
|
1442
|
+
const vertices = [
|
|
1443
|
+
swiz(r0[0], r1[0], r2[0]),
|
|
1444
|
+
swiz(r0[1], r1[0], r2[0]),
|
|
1445
|
+
swiz(r0[1], r1[1], r2[0]),
|
|
1446
|
+
swiz(r0[0], r1[1], r2[0]),
|
|
1447
|
+
swiz(r0[0], r1[0], r2[1]),
|
|
1448
|
+
swiz(r0[1], r1[0], r2[1]),
|
|
1449
|
+
swiz(r0[1], r1[1], r2[1]),
|
|
1450
|
+
swiz(r0[0], r1[1], r2[1]),
|
|
1451
|
+
];
|
|
1452
|
+
const edges = [
|
|
1453
|
+
[0, 1],
|
|
1454
|
+
[1, 2],
|
|
1455
|
+
[2, 3],
|
|
1456
|
+
[3, 0],
|
|
1457
|
+
[4, 5],
|
|
1458
|
+
[5, 6],
|
|
1459
|
+
[6, 7],
|
|
1460
|
+
[7, 4],
|
|
1461
|
+
[0, 4],
|
|
1462
|
+
[1, 5],
|
|
1463
|
+
[2, 6],
|
|
1464
|
+
[3, 7],
|
|
1465
|
+
];
|
|
1466
|
+
const positions = [];
|
|
1467
|
+
for (const [start_idx, end_idx] of edges) {
|
|
1468
|
+
const start = vertices[start_idx];
|
|
1469
|
+
const end = vertices[end_idx];
|
|
1470
|
+
positions.push(start[0], start[1], start[2], end[0], end[1], end[2]);
|
|
1471
|
+
}
|
|
1472
|
+
const geom = new THREE.BufferGeometry();
|
|
1473
|
+
geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
|
|
1474
|
+
return geom;
|
|
1475
|
+
});
|
|
1476
|
+
function reset_controls() {
|
|
1477
|
+
formal_chempots_override = null;
|
|
1478
|
+
label_stable_override = null;
|
|
1479
|
+
element_padding_override = null;
|
|
1480
|
+
default_min_limit_override = null;
|
|
1481
|
+
draw_formula_meshes_override = null;
|
|
1482
|
+
draw_formula_lines_override = null;
|
|
1483
|
+
color_mode_override = null;
|
|
1484
|
+
color_scale_override = null;
|
|
1485
|
+
reverse_color_scale_override = null;
|
|
1486
|
+
projection_elements_override = null;
|
|
1487
|
+
formulas_to_draw_override = null;
|
|
1488
|
+
formula_filter_query = ``;
|
|
1489
|
+
}
|
|
1490
|
+
function set_projection_axis(axis_idx, element) {
|
|
1491
|
+
if (!all_entry_elements.includes(element))
|
|
1492
|
+
return;
|
|
1493
|
+
const next_projection = [...plot_elements];
|
|
1494
|
+
if (next_projection.length !== 3)
|
|
1495
|
+
return;
|
|
1496
|
+
const current_owner_idx = next_projection.indexOf(element);
|
|
1497
|
+
if (current_owner_idx !== -1 && current_owner_idx !== axis_idx) {
|
|
1498
|
+
next_projection[current_owner_idx] = next_projection[axis_idx];
|
|
1499
|
+
}
|
|
1500
|
+
next_projection[axis_idx] = element;
|
|
1501
|
+
const normalized = normalize_projection_triplet(next_projection, all_entry_elements);
|
|
1502
|
+
if (normalized)
|
|
1503
|
+
projection_elements_override = normalized;
|
|
1504
|
+
}
|
|
1505
|
+
function apply_projection_preset(preset_elements) {
|
|
1506
|
+
const normalized = normalize_projection_triplet(preset_elements, all_entry_elements);
|
|
1507
|
+
if (normalized)
|
|
1508
|
+
projection_elements_override = normalized;
|
|
1509
|
+
}
|
|
1510
|
+
function toggle_formula_selection(formula) {
|
|
1511
|
+
const selected_formulas = new SvelteSet(formulas_to_draw);
|
|
1512
|
+
if (selected_formulas.has(formula))
|
|
1513
|
+
selected_formulas.delete(formula);
|
|
1514
|
+
else
|
|
1515
|
+
selected_formulas.add(formula);
|
|
1516
|
+
formulas_to_draw_override = [...selected_formulas];
|
|
1517
|
+
}
|
|
1518
|
+
function select_surface_formulas() {
|
|
1519
|
+
formulas_to_draw_override = render_domains
|
|
1520
|
+
.filter((domain) => surface_formulas.has(domain.formula))
|
|
1521
|
+
.map((domain) => domain.formula);
|
|
1522
|
+
}
|
|
1523
|
+
function select_neighbor_formulas() {
|
|
1524
|
+
if (hover_info?.view !== `3d`)
|
|
1525
|
+
return;
|
|
1526
|
+
const neighbors = domain_neighbors.get(hover_info.formula) ?? [];
|
|
1527
|
+
formulas_to_draw_override = [hover_info.formula, ...neighbors];
|
|
1528
|
+
}
|
|
1529
|
+
function download_blob(blob, filename) {
|
|
1530
|
+
const url = URL.createObjectURL(blob);
|
|
1531
|
+
const link = document.createElement(`a`);
|
|
1532
|
+
link.href = url;
|
|
1533
|
+
link.download = filename;
|
|
1534
|
+
link.click();
|
|
1535
|
+
URL.revokeObjectURL(url);
|
|
1536
|
+
}
|
|
1537
|
+
let png_dpi = $state(150);
|
|
1538
|
+
const export_basename = $derived(`chempot-${plot_elements.join(`-`)}`);
|
|
1539
|
+
function get_view_settings() {
|
|
1540
|
+
const camera_position = orbit_controls_ref?.object?.position;
|
|
1541
|
+
const camera_target = orbit_controls_ref?.target;
|
|
1542
|
+
return {
|
|
1543
|
+
elements: plot_elements,
|
|
1544
|
+
camera_projection,
|
|
1545
|
+
auto_rotate,
|
|
1546
|
+
color_mode,
|
|
1547
|
+
color_scale,
|
|
1548
|
+
reverse_color_scale,
|
|
1549
|
+
camera_position: camera_position
|
|
1550
|
+
? [camera_position.x, camera_position.y, camera_position.z]
|
|
1551
|
+
: null,
|
|
1552
|
+
camera_target: camera_target
|
|
1553
|
+
? [camera_target.x, camera_target.y, camera_target.z]
|
|
1554
|
+
: null,
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
function get_overlay_text_items(canvas_rect) {
|
|
1558
|
+
if (!wrapper)
|
|
1559
|
+
return [];
|
|
1560
|
+
const text_items = [];
|
|
1561
|
+
for (const element of wrapper.querySelectorAll(`.tick-label, .axis-label, .domain-label`)) {
|
|
1562
|
+
const html_element = element;
|
|
1563
|
+
const style = getComputedStyle(html_element);
|
|
1564
|
+
if (style.display === `none` || style.visibility === `hidden`)
|
|
1565
|
+
continue;
|
|
1566
|
+
const element_rect = html_element.getBoundingClientRect();
|
|
1567
|
+
text_items.push({
|
|
1568
|
+
x: element_rect.left + element_rect.width / 2 - canvas_rect.left,
|
|
1569
|
+
y: element_rect.top + element_rect.height / 2 - canvas_rect.top,
|
|
1570
|
+
text: html_element.textContent ?? ``,
|
|
1571
|
+
font: style.font || `${style.fontSize} ${style.fontFamily}`,
|
|
1572
|
+
font_size: style.fontSize || `11px`,
|
|
1573
|
+
font_family: style.fontFamily || `sans-serif`,
|
|
1574
|
+
font_weight: style.fontWeight || `400`,
|
|
1575
|
+
color: style.color || `#333`,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
return text_items;
|
|
1579
|
+
}
|
|
1580
|
+
function export_png_file() {
|
|
1581
|
+
if (!wrapper)
|
|
1582
|
+
return;
|
|
1583
|
+
const gl_canvas = wrapper.querySelector(`canvas`);
|
|
1584
|
+
if (!(gl_canvas instanceof HTMLCanvasElement))
|
|
1585
|
+
return;
|
|
1586
|
+
// Composite WebGL canvas + HTML overlay labels into a single image
|
|
1587
|
+
const rect = gl_canvas.getBoundingClientRect();
|
|
1588
|
+
const scale = Math.min(png_dpi / 72, 10);
|
|
1589
|
+
const out = document.createElement(`canvas`);
|
|
1590
|
+
out.width = Math.round(rect.width * scale);
|
|
1591
|
+
out.height = Math.round(rect.height * scale);
|
|
1592
|
+
const ctx = out.getContext(`2d`);
|
|
1593
|
+
if (!ctx)
|
|
1594
|
+
return;
|
|
1595
|
+
ctx.scale(scale, scale);
|
|
1596
|
+
// Draw the WebGL canvas as background
|
|
1597
|
+
ctx.drawImage(gl_canvas, 0, 0, rect.width, rect.height);
|
|
1598
|
+
// Draw all HTML overlay text (tick labels, axis labels, domain labels)
|
|
1599
|
+
for (const text_item of get_overlay_text_items(rect)) {
|
|
1600
|
+
ctx.font = text_item.font;
|
|
1601
|
+
ctx.fillStyle = text_item.color;
|
|
1602
|
+
ctx.textAlign = `center`;
|
|
1603
|
+
ctx.textBaseline = `middle`;
|
|
1604
|
+
ctx.fillText(text_item.text, text_item.x, text_item.y);
|
|
1605
|
+
}
|
|
1606
|
+
out.toBlob((blob) => {
|
|
1607
|
+
if (!blob)
|
|
1608
|
+
return;
|
|
1609
|
+
download_blob(blob, `${export_basename}.png`);
|
|
1610
|
+
}, `image/png`);
|
|
1611
|
+
}
|
|
1612
|
+
function xml_escape(text) {
|
|
1613
|
+
return text
|
|
1614
|
+
.replaceAll(`&`, `&`)
|
|
1615
|
+
.replaceAll(`<`, `<`)
|
|
1616
|
+
.replaceAll(`>`, `>`)
|
|
1617
|
+
.replaceAll(`"`, `"`)
|
|
1618
|
+
.replaceAll(`'`, `'`);
|
|
1619
|
+
}
|
|
1620
|
+
function export_svg_file() {
|
|
1621
|
+
if (!wrapper)
|
|
1622
|
+
return;
|
|
1623
|
+
const gl_canvas = wrapper.querySelector(`canvas`);
|
|
1624
|
+
if (!(gl_canvas instanceof HTMLCanvasElement))
|
|
1625
|
+
return;
|
|
1626
|
+
const canvas_rect = gl_canvas.getBoundingClientRect();
|
|
1627
|
+
if (canvas_rect.width === 0 || canvas_rect.height === 0)
|
|
1628
|
+
return;
|
|
1629
|
+
const png_data_url = gl_canvas.toDataURL(`image/png`);
|
|
1630
|
+
const text_nodes = get_overlay_text_items(canvas_rect).map((text_item) => `<text x="${text_item.x.toFixed(2)}" y="${text_item.y.toFixed(2)}" text-anchor="middle" dominant-baseline="central" fill="${xml_escape(text_item.color)}" font-size="${xml_escape(text_item.font_size)}" font-family="${xml_escape(text_item.font_family)}" font-weight="${xml_escape(text_item.font_weight)}">${xml_escape(text_item.text)}</text>`);
|
|
1631
|
+
const metadata = xml_escape(JSON.stringify(get_view_settings()));
|
|
1632
|
+
const svg = [
|
|
1633
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
1634
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvas_rect.width}" height="${canvas_rect.height}" viewBox="0 0 ${canvas_rect.width} ${canvas_rect.height}">`,
|
|
1635
|
+
`<metadata>${metadata}</metadata>`,
|
|
1636
|
+
`<image href="${png_data_url}" x="0" y="0" width="${canvas_rect.width}" height="${canvas_rect.height}" />`,
|
|
1637
|
+
...text_nodes,
|
|
1638
|
+
`</svg>`,
|
|
1639
|
+
].join(``);
|
|
1640
|
+
download_blob(new Blob([svg], { type: `image/svg+xml` }), `${export_basename}.svg`);
|
|
1641
|
+
}
|
|
1642
|
+
function export_view_json_file() {
|
|
1643
|
+
const json_text = JSON.stringify(get_view_settings(), null, 2);
|
|
1644
|
+
download_blob(new Blob([json_text], { type: `application/json` }), `${export_basename}-view.json`);
|
|
1645
|
+
}
|
|
1646
|
+
function export_glb_file() {
|
|
1647
|
+
const gltf_exporter = new GLTFExporter();
|
|
1648
|
+
const export_root = new THREE.Group();
|
|
1649
|
+
if (colored_hull_geometry) {
|
|
1650
|
+
export_root.add(new THREE.Mesh(colored_hull_geometry.clone(), new THREE.MeshBasicMaterial({
|
|
1651
|
+
vertexColors: true,
|
|
1652
|
+
transparent: true,
|
|
1653
|
+
opacity: color_mode === `none` ? 0.25 : 0.4,
|
|
1654
|
+
side: THREE.DoubleSide,
|
|
1655
|
+
})));
|
|
1656
|
+
}
|
|
1657
|
+
export_root.add(new THREE.LineSegments(edge_geometry.clone(), new THREE.LineBasicMaterial({ color: 0x333333 })));
|
|
1658
|
+
for (const { geometry, color } of formula_mesh_data) {
|
|
1659
|
+
export_root.add(new THREE.Mesh(geometry.clone(), new THREE.MeshBasicMaterial({
|
|
1660
|
+
color: new THREE.Color(color),
|
|
1661
|
+
transparent: true,
|
|
1662
|
+
opacity: 0.13,
|
|
1663
|
+
side: THREE.DoubleSide,
|
|
1664
|
+
})));
|
|
1665
|
+
}
|
|
1666
|
+
if (draw_formula_lines) {
|
|
1667
|
+
for (const { geometry, color } of formula_edge_data) {
|
|
1668
|
+
export_root.add(new THREE.LineSegments(geometry.clone(), new THREE.LineBasicMaterial({ color: new THREE.Color(color) })));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
gltf_exporter.parse(export_root, (result) => {
|
|
1672
|
+
if (!(result instanceof ArrayBuffer))
|
|
1673
|
+
return;
|
|
1674
|
+
download_blob(new Blob([result], { type: `model/gltf-binary` }), `${export_basename}.glb`);
|
|
1675
|
+
}, (err) => {
|
|
1676
|
+
console.error(`Failed to export GLB:`, err);
|
|
1677
|
+
}, { binary: true, onlyVisible: false });
|
|
1678
|
+
}
|
|
1679
|
+
function get_json_string() {
|
|
1680
|
+
return JSON.stringify({
|
|
1681
|
+
elements: diagram_data?.elements ?? [],
|
|
1682
|
+
domains: render_domains.map((domain) => ({
|
|
1683
|
+
formula: domain.formula,
|
|
1684
|
+
points_3d: domain.points_3d,
|
|
1685
|
+
})),
|
|
1686
|
+
lims: diagram_data?.lims ?? [],
|
|
1687
|
+
view: get_view_settings(),
|
|
1688
|
+
}, null, 2);
|
|
1689
|
+
}
|
|
1690
|
+
function export_json_file() {
|
|
1691
|
+
download_blob(new Blob([get_json_string()], { type: `application/json` }), `${export_basename}.json`);
|
|
1692
|
+
}
|
|
1693
|
+
async function copy_json() {
|
|
1694
|
+
try {
|
|
1695
|
+
await navigator.clipboard.writeText(get_json_string());
|
|
1696
|
+
copy_status = true;
|
|
1697
|
+
}
|
|
1698
|
+
catch (err) {
|
|
1699
|
+
copy_status = false;
|
|
1700
|
+
console.error(`Failed to copy JSON to clipboard:`, err);
|
|
1701
|
+
}
|
|
1702
|
+
if (copy_timeout_id !== null)
|
|
1703
|
+
clearTimeout(copy_timeout_id);
|
|
1704
|
+
copy_timeout_id = setTimeout(() => {
|
|
1705
|
+
copy_status = false;
|
|
1706
|
+
copy_timeout_id = null;
|
|
1707
|
+
}, 1000);
|
|
1708
|
+
}
|
|
1709
|
+
onDestroy(() => {
|
|
1710
|
+
if (copy_timeout_id !== null)
|
|
1711
|
+
clearTimeout(copy_timeout_id);
|
|
1712
|
+
if (fixed_container_frame_id !== null) {
|
|
1713
|
+
cancelAnimationFrame(fixed_container_frame_id);
|
|
1714
|
+
fixed_container_frame_id = null;
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
function find_fixed_container_element() {
|
|
1718
|
+
if (!wrapper)
|
|
1719
|
+
return null;
|
|
1720
|
+
let ancestor_element = wrapper.parentElement;
|
|
1721
|
+
while (ancestor_element && ancestor_element !== document.documentElement) {
|
|
1722
|
+
const computed_style = getComputedStyle(ancestor_element);
|
|
1723
|
+
const contain_value = computed_style.contain;
|
|
1724
|
+
const container_type_value = computed_style.getPropertyValue(`container-type`)
|
|
1725
|
+
.trim();
|
|
1726
|
+
const creates_fixed_containing_block = computed_style.transform !== `none` ||
|
|
1727
|
+
computed_style.perspective !== `none` ||
|
|
1728
|
+
computed_style.filter !== `none` ||
|
|
1729
|
+
computed_style.backdropFilter !== `none` ||
|
|
1730
|
+
contain_value.includes(`layout`) ||
|
|
1731
|
+
contain_value.includes(`paint`) ||
|
|
1732
|
+
contain_value.includes(`strict`) ||
|
|
1733
|
+
contain_value.includes(`content`) ||
|
|
1734
|
+
(container_type_value && container_type_value !== `normal`);
|
|
1735
|
+
if (creates_fixed_containing_block)
|
|
1736
|
+
return ancestor_element;
|
|
1737
|
+
ancestor_element = ancestor_element.parentElement;
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
function refresh_fixed_container_rect(container_element = fixed_container_element) {
|
|
1742
|
+
fixed_container_rect = container_element?.getBoundingClientRect() ?? null;
|
|
1743
|
+
}
|
|
1744
|
+
function queue_fixed_container_rect_refresh() {
|
|
1745
|
+
if (fixed_container_frame_id !== null)
|
|
1746
|
+
return;
|
|
1747
|
+
fixed_container_frame_id = requestAnimationFrame(() => {
|
|
1748
|
+
fixed_container_frame_id = null;
|
|
1749
|
+
refresh_fixed_container_rect();
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
$effect(() => {
|
|
1753
|
+
const next_fixed_container_element = find_fixed_container_element();
|
|
1754
|
+
fixed_container_element = next_fixed_container_element;
|
|
1755
|
+
refresh_fixed_container_rect(next_fixed_container_element);
|
|
1756
|
+
});
|
|
1757
|
+
onMount(() => {
|
|
1758
|
+
const handle_layout_change = () => queue_fixed_container_rect_refresh();
|
|
1759
|
+
window.addEventListener(`resize`, handle_layout_change);
|
|
1760
|
+
window.addEventListener(`scroll`, handle_layout_change, true);
|
|
1761
|
+
return () => {
|
|
1762
|
+
window.removeEventListener(`resize`, handle_layout_change);
|
|
1763
|
+
window.removeEventListener(`scroll`, handle_layout_change, true);
|
|
1764
|
+
};
|
|
1765
|
+
});
|
|
1766
|
+
let locked_hover_formula = $state(null);
|
|
1767
|
+
function set_hover_info(domain_data, raw_event) {
|
|
1768
|
+
hover_info = with_hover_pointer(domain_data.info, raw_event, fixed_container_rect);
|
|
1769
|
+
}
|
|
1770
|
+
function clear_hover_lock() {
|
|
1771
|
+
locked_hover_formula = null;
|
|
1772
|
+
hover_info = null;
|
|
1773
|
+
}
|
|
1774
|
+
function handle_phase_hover(domain_data, raw_event) {
|
|
1775
|
+
if (locked_hover_formula && locked_hover_formula !== domain_data.formula)
|
|
1776
|
+
return;
|
|
1777
|
+
set_hover_info(domain_data, raw_event);
|
|
1778
|
+
}
|
|
1779
|
+
function toggle_phase_lock(domain_data, raw_event) {
|
|
1780
|
+
if (locked_hover_formula === domain_data.formula) {
|
|
1781
|
+
clear_hover_lock();
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
locked_hover_formula = domain_data.formula;
|
|
1785
|
+
set_hover_info(domain_data, raw_event);
|
|
1786
|
+
}
|
|
1787
|
+
// Color mode cycling (keyboard shortcut 'c')
|
|
1788
|
+
const color_modes = [
|
|
1789
|
+
`none`,
|
|
1790
|
+
`energy`,
|
|
1791
|
+
`formation_energy`,
|
|
1792
|
+
`arity`,
|
|
1793
|
+
`entries`,
|
|
1794
|
+
];
|
|
1795
|
+
function cycle_color_mode() {
|
|
1796
|
+
const idx = color_modes.indexOf(color_mode);
|
|
1797
|
+
color_mode_override = color_modes[(idx + 1) % color_modes.length];
|
|
1798
|
+
}
|
|
1799
|
+
</script>
|
|
1800
|
+
|
|
1801
|
+
<svelte:document
|
|
1802
|
+
onfullscreenchange={() => {
|
|
1803
|
+
fullscreen = document.fullscreenElement === wrapper
|
|
1804
|
+
}}
|
|
1805
|
+
/>
|
|
1806
|
+
|
|
1807
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
1808
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1809
|
+
<div
|
|
1810
|
+
bind:this={wrapper}
|
|
1811
|
+
bind:clientWidth={container_width}
|
|
1812
|
+
bind:clientHeight={container_height}
|
|
1813
|
+
class="chempot-diagram-3d"
|
|
1814
|
+
class:fullscreen
|
|
1815
|
+
style:width={fullscreen ? `100vw` : `100%`}
|
|
1816
|
+
style:height={fullscreen ? `100vh` : `${render_height}px`}
|
|
1817
|
+
role="application"
|
|
1818
|
+
tabindex="0"
|
|
1819
|
+
onkeydown={(event) => {
|
|
1820
|
+
if (
|
|
1821
|
+
event.target instanceof HTMLInputElement ||
|
|
1822
|
+
event.target instanceof HTMLSelectElement
|
|
1823
|
+
) return
|
|
1824
|
+
if (event.key === `Escape`) clear_hover_lock()
|
|
1825
|
+
else if (event.key === `c`) cycle_color_mode()
|
|
1826
|
+
else if (event.key === `f`) toggle_fullscreen(wrapper)
|
|
1827
|
+
}}
|
|
1828
|
+
onpointerdown={(event) => {
|
|
1829
|
+
const target = event.target
|
|
1830
|
+
if (
|
|
1831
|
+
locked_hover_formula &&
|
|
1832
|
+
(target === wrapper || target instanceof HTMLCanvasElement)
|
|
1833
|
+
) {
|
|
1834
|
+
clear_hover_lock()
|
|
1835
|
+
}
|
|
1836
|
+
}}
|
|
1837
|
+
>
|
|
1838
|
+
<section>
|
|
1839
|
+
<DraggablePane
|
|
1840
|
+
bind:show={export_pane_open}
|
|
1841
|
+
open_icon="Cross"
|
|
1842
|
+
closed_icon="Export"
|
|
1843
|
+
pane_props={{ class: `chempot-export-pane` }}
|
|
1844
|
+
toggle_props={{
|
|
1845
|
+
class: `chempot-export-toggle`,
|
|
1846
|
+
title: `Export chemical potential diagram`,
|
|
1847
|
+
}}
|
|
1848
|
+
>
|
|
1849
|
+
<h4 id="export-image">Export Image</h4>
|
|
1850
|
+
<div class="export-row">
|
|
1851
|
+
<label>
|
|
1852
|
+
SVG
|
|
1853
|
+
<button type="button" onclick={export_svg_file} title="SVG snapshot export">
|
|
1854
|
+
⬇
|
|
1855
|
+
</button>
|
|
1856
|
+
</label>
|
|
1857
|
+
<label>
|
|
1858
|
+
PNG
|
|
1859
|
+
<button type="button" onclick={export_png_file} title="PNG ({png_dpi} DPI)">
|
|
1860
|
+
⬇
|
|
1861
|
+
</button>
|
|
1862
|
+
(DPI: <input
|
|
1863
|
+
type="number"
|
|
1864
|
+
min={50}
|
|
1865
|
+
max={500}
|
|
1866
|
+
bind:value={png_dpi}
|
|
1867
|
+
title="Export resolution in dots per inch"
|
|
1868
|
+
style="margin: 0 0 0 2pt"
|
|
1869
|
+
/>)
|
|
1870
|
+
</label>
|
|
1871
|
+
</div>
|
|
1872
|
+
<h4 id="export-data">Export Data</h4>
|
|
1873
|
+
<div class="export-row">
|
|
1874
|
+
<label>
|
|
1875
|
+
JSON
|
|
1876
|
+
<button type="button" onclick={export_json_file} aria-label="Download JSON">
|
|
1877
|
+
⬇
|
|
1878
|
+
</button>
|
|
1879
|
+
<button
|
|
1880
|
+
type="button"
|
|
1881
|
+
onclick={copy_json}
|
|
1882
|
+
aria-label="Copy JSON to clipboard"
|
|
1883
|
+
>
|
|
1884
|
+
{copy_status ? `✅` : `📋`}
|
|
1885
|
+
</button>
|
|
1886
|
+
</label>
|
|
1887
|
+
<label>
|
|
1888
|
+
View
|
|
1889
|
+
<button
|
|
1890
|
+
type="button"
|
|
1891
|
+
onclick={export_view_json_file}
|
|
1892
|
+
aria-label="Download view JSON"
|
|
1893
|
+
>
|
|
1894
|
+
⬇
|
|
1895
|
+
</button>
|
|
1896
|
+
</label>
|
|
1897
|
+
<label>
|
|
1898
|
+
GLB
|
|
1899
|
+
<button type="button" onclick={export_glb_file} aria-label="Download GLB">
|
|
1900
|
+
⬇
|
|
1901
|
+
</button>
|
|
1902
|
+
</label>
|
|
1903
|
+
</div>
|
|
1904
|
+
</DraggablePane>
|
|
1905
|
+
<DraggablePane
|
|
1906
|
+
bind:show={formula_picker_open}
|
|
1907
|
+
open_icon="Cross"
|
|
1908
|
+
closed_icon="Filter"
|
|
1909
|
+
pane_props={{ class: `chempot-formula-pane` }}
|
|
1910
|
+
toggle_props={{
|
|
1911
|
+
class: `chempot-formula-toggle`,
|
|
1912
|
+
title: `Formula overlays`,
|
|
1913
|
+
}}
|
|
1914
|
+
>
|
|
1915
|
+
<h4 id="formula-overlays">Formula Overlays</h4>
|
|
1916
|
+
<div class="overlay-actions">
|
|
1917
|
+
<button type="button" onclick={() => formulas_to_draw_override = []}>
|
|
1918
|
+
Clear
|
|
1919
|
+
</button>
|
|
1920
|
+
<button type="button" onclick={select_surface_formulas}>Surface</button>
|
|
1921
|
+
<button type="button" onclick={select_neighbor_formulas}>Neighbors</button>
|
|
1922
|
+
</div>
|
|
1923
|
+
<label class="overlay-search">
|
|
1924
|
+
Search:
|
|
1925
|
+
<input
|
|
1926
|
+
type="text"
|
|
1927
|
+
placeholder="Formula filter"
|
|
1928
|
+
bind:value={formula_filter_query}
|
|
1929
|
+
/>
|
|
1930
|
+
</label>
|
|
1931
|
+
<div class="formula-list">
|
|
1932
|
+
{#if filtered_formulas.length === 0}
|
|
1933
|
+
<div class="formula-empty">No matching formulas</div>
|
|
1934
|
+
{:else}
|
|
1935
|
+
{#each filtered_formulas as formula, formula_idx (formula)}
|
|
1936
|
+
{@const formula_overlay_idx = formulas_to_draw.indexOf(formula)}
|
|
1937
|
+
<label>
|
|
1938
|
+
<input
|
|
1939
|
+
type="checkbox"
|
|
1940
|
+
checked={formulas_to_draw.includes(formula)}
|
|
1941
|
+
onchange={() => toggle_formula_selection(formula)}
|
|
1942
|
+
/>
|
|
1943
|
+
<span
|
|
1944
|
+
class="formula-color-dot"
|
|
1945
|
+
style:background={formula_colors[
|
|
1946
|
+
(formula_overlay_idx >= 0 ? formula_overlay_idx : formula_idx) %
|
|
1947
|
+
formula_colors.length
|
|
1948
|
+
]}
|
|
1949
|
+
></span>
|
|
1950
|
+
{get_hill_formula(formula, true, ``)}
|
|
1951
|
+
</label>
|
|
1952
|
+
{/each}
|
|
1953
|
+
{/if}
|
|
1954
|
+
</div>
|
|
1955
|
+
</DraggablePane>
|
|
1956
|
+
|
|
1957
|
+
<ScatterPlot3DControls
|
|
1958
|
+
bind:x_axis
|
|
1959
|
+
bind:y_axis
|
|
1960
|
+
bind:z_axis
|
|
1961
|
+
bind:display
|
|
1962
|
+
bind:camera_projection
|
|
1963
|
+
bind:auto_rotate
|
|
1964
|
+
series={controls_series}
|
|
1965
|
+
toggle_props={{
|
|
1966
|
+
class: `chempot-controls-toggle`,
|
|
1967
|
+
title: `3D plot controls`,
|
|
1968
|
+
}}
|
|
1969
|
+
pane_props={{ class: `chempot-controls-pane` }}
|
|
1970
|
+
>
|
|
1971
|
+
<SettingsSection
|
|
1972
|
+
title="ChemPot"
|
|
1973
|
+
current_values={{
|
|
1974
|
+
formal_chempots,
|
|
1975
|
+
label_stable,
|
|
1976
|
+
element_padding,
|
|
1977
|
+
default_min_limit,
|
|
1978
|
+
draw_formula_meshes,
|
|
1979
|
+
draw_formula_lines,
|
|
1980
|
+
color_mode,
|
|
1981
|
+
color_scale,
|
|
1982
|
+
reverse_color_scale,
|
|
1983
|
+
}}
|
|
1984
|
+
on_reset={reset_controls}
|
|
1985
|
+
>
|
|
1986
|
+
{#if has_multinary_system && plot_elements.length === 3}
|
|
1987
|
+
<div class="projection-controls">
|
|
1988
|
+
<div class="pane-row">
|
|
1989
|
+
<label for="chempot-proj-x">X:</label>
|
|
1990
|
+
<select
|
|
1991
|
+
id="chempot-proj-x"
|
|
1992
|
+
value={plot_elements[0]}
|
|
1993
|
+
onchange={(event) => set_projection_axis(0, event.currentTarget.value)}
|
|
1994
|
+
>
|
|
1995
|
+
{#each all_entry_elements as element_name (element_name)}
|
|
1996
|
+
<option value={element_name}>{element_name}</option>
|
|
1997
|
+
{/each}
|
|
1998
|
+
</select>
|
|
1999
|
+
<label for="chempot-proj-y">Y:</label>
|
|
2000
|
+
<select
|
|
2001
|
+
id="chempot-proj-y"
|
|
2002
|
+
value={plot_elements[1]}
|
|
2003
|
+
onchange={(event) => set_projection_axis(1, event.currentTarget.value)}
|
|
2004
|
+
>
|
|
2005
|
+
{#each all_entry_elements as element_name (element_name)}
|
|
2006
|
+
<option value={element_name}>{element_name}</option>
|
|
2007
|
+
{/each}
|
|
2008
|
+
</select>
|
|
2009
|
+
<label for="chempot-proj-z">Z:</label>
|
|
2010
|
+
<select
|
|
2011
|
+
id="chempot-proj-z"
|
|
2012
|
+
value={plot_elements[2]}
|
|
2013
|
+
onchange={(event) => set_projection_axis(2, event.currentTarget.value)}
|
|
2014
|
+
>
|
|
2015
|
+
{#each all_entry_elements as element_name (element_name)}
|
|
2016
|
+
<option value={element_name}>{element_name}</option>
|
|
2017
|
+
{/each}
|
|
2018
|
+
</select>
|
|
2019
|
+
</div>
|
|
2020
|
+
<div class="projection-presets">
|
|
2021
|
+
{#each projection_presets as preset_elements (preset_elements.join(`|`))}
|
|
2022
|
+
<button
|
|
2023
|
+
type="button"
|
|
2024
|
+
class:selected={preset_elements.join(`|`) === current_projection_key}
|
|
2025
|
+
onclick={() => apply_projection_preset(preset_elements)}
|
|
2026
|
+
title="Switch projection"
|
|
2027
|
+
>
|
|
2028
|
+
{preset_elements.join(`-`)}
|
|
2029
|
+
</button>
|
|
2030
|
+
{/each}
|
|
2031
|
+
</div>
|
|
2032
|
+
</div>
|
|
2033
|
+
{/if}
|
|
2034
|
+
<div class="chempot-checks">
|
|
2035
|
+
<label>
|
|
2036
|
+
<input
|
|
2037
|
+
type="checkbox"
|
|
2038
|
+
checked={formal_chempots}
|
|
2039
|
+
onchange={() => {
|
|
2040
|
+
formal_chempots_override = !formal_chempots
|
|
2041
|
+
}}
|
|
2042
|
+
/> Formal
|
|
2043
|
+
</label>
|
|
2044
|
+
<label>
|
|
2045
|
+
<input
|
|
2046
|
+
type="checkbox"
|
|
2047
|
+
checked={label_stable}
|
|
2048
|
+
onchange={() => {
|
|
2049
|
+
label_stable_override = !label_stable
|
|
2050
|
+
}}
|
|
2051
|
+
/> Labels
|
|
2052
|
+
</label>
|
|
2053
|
+
<label>
|
|
2054
|
+
<input
|
|
2055
|
+
type="checkbox"
|
|
2056
|
+
checked={draw_formula_meshes}
|
|
2057
|
+
onchange={() => {
|
|
2058
|
+
draw_formula_meshes_override = !draw_formula_meshes
|
|
2059
|
+
}}
|
|
2060
|
+
/> Meshes
|
|
2061
|
+
</label>
|
|
2062
|
+
<label>
|
|
2063
|
+
<input
|
|
2064
|
+
type="checkbox"
|
|
2065
|
+
checked={draw_formula_lines}
|
|
2066
|
+
onchange={() => {
|
|
2067
|
+
draw_formula_lines_override = !draw_formula_lines
|
|
2068
|
+
}}
|
|
2069
|
+
/> Lines
|
|
2070
|
+
</label>
|
|
2071
|
+
</div>
|
|
2072
|
+
<div class="chempot-nums">
|
|
2073
|
+
<label>
|
|
2074
|
+
Pad (eV)
|
|
2075
|
+
<input
|
|
2076
|
+
type="number"
|
|
2077
|
+
min="0"
|
|
2078
|
+
step="0.1"
|
|
2079
|
+
value={element_padding}
|
|
2080
|
+
oninput={(event) => {
|
|
2081
|
+
element_padding_override = Number(event.currentTarget.value)
|
|
2082
|
+
}}
|
|
2083
|
+
/>
|
|
2084
|
+
</label>
|
|
2085
|
+
<label>
|
|
2086
|
+
Min (eV)
|
|
2087
|
+
<input
|
|
2088
|
+
type="number"
|
|
2089
|
+
max="0"
|
|
2090
|
+
step="1"
|
|
2091
|
+
value={default_min_limit}
|
|
2092
|
+
oninput={(event) => {
|
|
2093
|
+
default_min_limit_override = Number(
|
|
2094
|
+
event.currentTarget.value,
|
|
2095
|
+
)
|
|
2096
|
+
}}
|
|
2097
|
+
/>
|
|
2098
|
+
</label>
|
|
2099
|
+
</div>
|
|
2100
|
+
<div class="pane-row">
|
|
2101
|
+
<label for="chempot-color-mode">Color:</label>
|
|
2102
|
+
<select
|
|
2103
|
+
id="chempot-color-mode"
|
|
2104
|
+
value={color_mode}
|
|
2105
|
+
onchange={(event) => {
|
|
2106
|
+
color_mode_override = event.currentTarget
|
|
2107
|
+
.value as ChemPotColorMode
|
|
2108
|
+
}}
|
|
2109
|
+
>
|
|
2110
|
+
<option value="none">None</option>
|
|
2111
|
+
<option value="energy">Energy/atom</option>
|
|
2112
|
+
<option value="formation_energy">Formation energy</option>
|
|
2113
|
+
<option value="arity">Element count</option>
|
|
2114
|
+
<option value="entries">Entry count</option>
|
|
2115
|
+
</select>
|
|
2116
|
+
</div>
|
|
2117
|
+
{#if color_mode !== `none` && color_mode !== `arity`}
|
|
2118
|
+
<div class="pane-row">
|
|
2119
|
+
<label for="chempot-color-scale">Scale:</label>
|
|
2120
|
+
<select
|
|
2121
|
+
id="chempot-color-scale"
|
|
2122
|
+
value={color_scale}
|
|
2123
|
+
onchange={(event) => {
|
|
2124
|
+
color_scale_override = event.currentTarget
|
|
2125
|
+
.value as D3InterpolateName
|
|
2126
|
+
}}
|
|
2127
|
+
>
|
|
2128
|
+
<option value="interpolateViridis">Viridis</option>
|
|
2129
|
+
<option value="interpolatePlasma">Plasma</option>
|
|
2130
|
+
<option value="interpolateInferno">Inferno</option>
|
|
2131
|
+
<option value="interpolateMagma">Magma</option>
|
|
2132
|
+
<option value="interpolateCividis">Cividis</option>
|
|
2133
|
+
<option value="interpolateTurbo">Turbo</option>
|
|
2134
|
+
<option value="interpolateRdYlBu">RdYlBu</option>
|
|
2135
|
+
<option value="interpolateSpectral">Spectral</option>
|
|
2136
|
+
</select>
|
|
2137
|
+
<label>
|
|
2138
|
+
<input
|
|
2139
|
+
type="checkbox"
|
|
2140
|
+
checked={reverse_color_scale}
|
|
2141
|
+
onchange={() => {
|
|
2142
|
+
reverse_color_scale_override = !reverse_color_scale
|
|
2143
|
+
}}
|
|
2144
|
+
/> Rev
|
|
2145
|
+
</label>
|
|
2146
|
+
</div>
|
|
2147
|
+
{/if}
|
|
2148
|
+
</SettingsSection>
|
|
2149
|
+
</ScatterPlot3DControls>
|
|
2150
|
+
|
|
2151
|
+
<button
|
|
2152
|
+
type="button"
|
|
2153
|
+
onclick={() => toggle_fullscreen(wrapper)}
|
|
2154
|
+
title="{fullscreen ? `Exit` : `Enter`} fullscreen"
|
|
2155
|
+
>
|
|
2156
|
+
<Icon icon="{fullscreen ? `Exit` : ``}Fullscreen" />
|
|
2157
|
+
</button>
|
|
2158
|
+
</section>
|
|
2159
|
+
{#if show_temperature_slider && temperature !== undefined}
|
|
2160
|
+
<TemperatureSlider
|
|
2161
|
+
class="chempot-temp-slider"
|
|
2162
|
+
{available_temperatures}
|
|
2163
|
+
bind:temperature
|
|
2164
|
+
/>
|
|
2165
|
+
{/if}
|
|
2166
|
+
{#if !diagram_data}
|
|
2167
|
+
<div class="error-state" role="alert" aria-live="polite">
|
|
2168
|
+
<p>Cannot compute chemical potential diagram.</p>
|
|
2169
|
+
<p>Need at least 2 elements with elemental reference entries.</p>
|
|
2170
|
+
</div>
|
|
2171
|
+
{:else if mounted && typeof WebGLRenderingContext !== `undefined`}
|
|
2172
|
+
<Canvas
|
|
2173
|
+
createRenderer={(cvs) =>
|
|
2174
|
+
new THREE.WebGLRenderer({
|
|
2175
|
+
canvas: cvs,
|
|
2176
|
+
alpha: true,
|
|
2177
|
+
antialias: true,
|
|
2178
|
+
preserveDrawingBuffer: true,
|
|
2179
|
+
})}
|
|
2180
|
+
>
|
|
2181
|
+
<ChemPotScene3D>
|
|
2182
|
+
{#if camera_projection === `orthographic`}
|
|
2183
|
+
<!-- Orthographic camera matching pymatgen's projection style -->
|
|
2184
|
+
<T.OrthographicCamera
|
|
2185
|
+
makeDefault
|
|
2186
|
+
position={camera_position}
|
|
2187
|
+
zoom={orthographic_zoom}
|
|
2188
|
+
near={0.1}
|
|
2189
|
+
far={data_extent * 10}
|
|
2190
|
+
>
|
|
2191
|
+
<extras.OrbitControls
|
|
2192
|
+
bind:ref={orbit_controls_ref}
|
|
2193
|
+
enableRotate
|
|
2194
|
+
enableZoom
|
|
2195
|
+
enablePan
|
|
2196
|
+
autoRotate={auto_rotate > 0}
|
|
2197
|
+
autoRotateSpeed={auto_rotate}
|
|
2198
|
+
target={camera_target}
|
|
2199
|
+
/>
|
|
2200
|
+
</T.OrthographicCamera>
|
|
2201
|
+
{:else}
|
|
2202
|
+
<T.PerspectiveCamera
|
|
2203
|
+
makeDefault
|
|
2204
|
+
position={camera_position}
|
|
2205
|
+
fov={50}
|
|
2206
|
+
near={0.1}
|
|
2207
|
+
far={data_extent * 10}
|
|
2208
|
+
>
|
|
2209
|
+
<extras.OrbitControls
|
|
2210
|
+
bind:ref={orbit_controls_ref}
|
|
2211
|
+
enableRotate
|
|
2212
|
+
enableZoom
|
|
2213
|
+
enablePan
|
|
2214
|
+
autoRotate={auto_rotate > 0}
|
|
2215
|
+
autoRotateSpeed={auto_rotate}
|
|
2216
|
+
target={camera_target}
|
|
2217
|
+
/>
|
|
2218
|
+
</T.PerspectiveCamera>
|
|
2219
|
+
{/if}
|
|
2220
|
+
|
|
2221
|
+
<!-- Ambient light for visibility -->
|
|
2222
|
+
<T.AmbientLight intensity={0.8} />
|
|
2223
|
+
<T.DirectionalLight position={[1, 1, 1]} intensity={0.5} />
|
|
2224
|
+
|
|
2225
|
+
<!-- Vertex-colored hull for both plain and colored modes.
|
|
2226
|
+
{#key domain_colors} forces Threlte to re-create the mesh whenever
|
|
2227
|
+
colors change (covers color_mode, color_scale, and data updates),
|
|
2228
|
+
since on-demand rendering won't detect mutated vertex color buffers. -->
|
|
2229
|
+
{#if colored_hull_geometry}
|
|
2230
|
+
{#key domain_colors}
|
|
2231
|
+
<T.Mesh geometry={colored_hull_geometry}>
|
|
2232
|
+
<T.MeshBasicMaterial
|
|
2233
|
+
vertexColors
|
|
2234
|
+
transparent
|
|
2235
|
+
opacity={color_mode === `none` ? 0.25 : 0.4}
|
|
2236
|
+
side={THREE.DoubleSide}
|
|
2237
|
+
polygonOffset
|
|
2238
|
+
polygonOffsetFactor={1}
|
|
2239
|
+
polygonOffsetUnits={1}
|
|
2240
|
+
/>
|
|
2241
|
+
</T.Mesh>
|
|
2242
|
+
{/key}
|
|
2243
|
+
{/if}
|
|
2244
|
+
|
|
2245
|
+
<!-- Domain boundary edges (wireframe on top of opaque fills) -->
|
|
2246
|
+
<T.LineSegments geometry={edge_geometry}>
|
|
2247
|
+
<T.LineBasicMaterial color={0x333333} linewidth={1} />
|
|
2248
|
+
</T.LineSegments>
|
|
2249
|
+
|
|
2250
|
+
<!-- Invisible pick meshes for per-phase hover tooltip -->
|
|
2251
|
+
{#each hover_mesh_data as domain_hover (domain_hover.formula)}
|
|
2252
|
+
<T.Mesh
|
|
2253
|
+
geometry={domain_hover.geometry}
|
|
2254
|
+
onpointerenter={(event: unknown) => handle_phase_hover(domain_hover, event)}
|
|
2255
|
+
onpointermove={(event: unknown) => handle_phase_hover(domain_hover, event)}
|
|
2256
|
+
onpointerdown={(event: unknown) => toggle_phase_lock(domain_hover, event)}
|
|
2257
|
+
onpointerleave={() => {
|
|
2258
|
+
if (
|
|
2259
|
+
!locked_hover_formula &&
|
|
2260
|
+
hover_info?.formula === domain_hover.formula
|
|
2261
|
+
) {
|
|
2262
|
+
hover_info = null
|
|
2263
|
+
}
|
|
2264
|
+
}}
|
|
2265
|
+
>
|
|
2266
|
+
<T.MeshBasicMaterial
|
|
2267
|
+
transparent
|
|
2268
|
+
opacity={0}
|
|
2269
|
+
side={THREE.DoubleSide}
|
|
2270
|
+
depthWrite={false}
|
|
2271
|
+
/>
|
|
2272
|
+
</T.Mesh>
|
|
2273
|
+
{/each}
|
|
2274
|
+
|
|
2275
|
+
<!-- Formula overlay meshes (semi-transparent colored fill) -->
|
|
2276
|
+
{#each formula_mesh_data as { geometry, color }, mesh_idx (mesh_idx)}
|
|
2277
|
+
<T.Mesh {geometry}>
|
|
2278
|
+
<T.MeshBasicMaterial
|
|
2279
|
+
color={new THREE.Color(color)}
|
|
2280
|
+
transparent
|
|
2281
|
+
opacity={0.13}
|
|
2282
|
+
side={THREE.DoubleSide}
|
|
2283
|
+
depthWrite={false}
|
|
2284
|
+
/>
|
|
2285
|
+
</T.Mesh>
|
|
2286
|
+
{/each}
|
|
2287
|
+
|
|
2288
|
+
<!-- Formula overlay edges (colored, thicker) -->
|
|
2289
|
+
{#if draw_formula_lines}
|
|
2290
|
+
{#each formula_edge_data as { geometry, color }, edge_idx (edge_idx)}
|
|
2291
|
+
<T.LineSegments {geometry}>
|
|
2292
|
+
<T.LineBasicMaterial color={new THREE.Color(color)} linewidth={2} />
|
|
2293
|
+
</T.LineSegments>
|
|
2294
|
+
{/each}
|
|
2295
|
+
{/if}
|
|
2296
|
+
|
|
2297
|
+
{#each projection_planes as plane (`${plane.key}-${projection_opacity}`)}
|
|
2298
|
+
<T.Mesh position={plane.pos} rotation={plane.rot}>
|
|
2299
|
+
<T.PlaneGeometry args={plane.size} />
|
|
2300
|
+
<T.MeshBasicMaterial
|
|
2301
|
+
color={plane.color}
|
|
2302
|
+
opacity={projection_opacity}
|
|
2303
|
+
transparent
|
|
2304
|
+
side={THREE.DoubleSide}
|
|
2305
|
+
depthWrite={false}
|
|
2306
|
+
/>
|
|
2307
|
+
</T.Mesh>
|
|
2308
|
+
{/each}
|
|
2309
|
+
|
|
2310
|
+
{#if display.show_bounding_box}
|
|
2311
|
+
<T.LineSegments geometry={bounding_box_geometry}>
|
|
2312
|
+
<T.LineBasicMaterial color="#666" opacity={0.6} transparent />
|
|
2313
|
+
</T.LineSegments>
|
|
2314
|
+
{/if}
|
|
2315
|
+
|
|
2316
|
+
<!-- Axes, ticks, grid lines, and labels -->
|
|
2317
|
+
{#each grid_config as gc (gc.axis)}
|
|
2318
|
+
{#if display.show_axes}
|
|
2319
|
+
<!-- Main axis line -->
|
|
2320
|
+
<T.Line geometry={gc.line_geom}>
|
|
2321
|
+
<T.LineBasicMaterial color={gc.color} linewidth={2} />
|
|
2322
|
+
</T.Line>
|
|
2323
|
+
<!-- Tick marks -->
|
|
2324
|
+
{#each gc.tick_geoms as tick_geom, tdx (tdx)}
|
|
2325
|
+
<T.Line geometry={tick_geom}>
|
|
2326
|
+
<T.LineBasicMaterial color={gc.color} />
|
|
2327
|
+
</T.Line>
|
|
2328
|
+
{/each}
|
|
2329
|
+
{/if}
|
|
2330
|
+
{#if display.show_grid}
|
|
2331
|
+
<!-- Grid lines -->
|
|
2332
|
+
{#each gc.grid_geoms as grid_geom, gdx (gdx)}
|
|
2333
|
+
<T.Line geometry={grid_geom}>
|
|
2334
|
+
<T.LineBasicMaterial color="#888" opacity={0.3} transparent />
|
|
2335
|
+
</T.Line>
|
|
2336
|
+
{/each}
|
|
2337
|
+
{/if}
|
|
2338
|
+
{#if display.show_axis_labels}
|
|
2339
|
+
<!-- Tick labels (billboarded, always face camera) -->
|
|
2340
|
+
{#each gc.tick_labels as tick, tick_idx (tick_idx)}
|
|
2341
|
+
<extras.HTML
|
|
2342
|
+
position={tick.pos}
|
|
2343
|
+
center
|
|
2344
|
+
portal={wrapper}
|
|
2345
|
+
zIndexRange={[1, 0]}
|
|
2346
|
+
>
|
|
2347
|
+
<span class="tick-label">{tick.text}</span>
|
|
2348
|
+
</extras.HTML>
|
|
2349
|
+
{/each}
|
|
2350
|
+
<!-- Axis label -->
|
|
2351
|
+
<extras.HTML
|
|
2352
|
+
position={gc.label_pos}
|
|
2353
|
+
center
|
|
2354
|
+
portal={wrapper}
|
|
2355
|
+
zIndexRange={[1, 0]}
|
|
2356
|
+
>
|
|
2357
|
+
<span class="axis-label" style:color={gc.color}>{gc.label}</span>
|
|
2358
|
+
</extras.HTML>
|
|
2359
|
+
{/if}
|
|
2360
|
+
{/each}
|
|
2361
|
+
|
|
2362
|
+
<!-- Domain labels (only for surface domains, not interior ones) -->
|
|
2363
|
+
{#if label_stable}
|
|
2364
|
+
{#each render_domains.filter((d) => surface_formulas.has(d.formula)) as
|
|
2365
|
+
domain
|
|
2366
|
+
(domain.formula)
|
|
2367
|
+
}
|
|
2368
|
+
<extras.HTML
|
|
2369
|
+
position={swiz(domain.label_loc[0], domain.label_loc[1], domain.label_loc[2])}
|
|
2370
|
+
center
|
|
2371
|
+
portal={wrapper}
|
|
2372
|
+
zIndexRange={[1, 0]}
|
|
2373
|
+
>
|
|
2374
|
+
<span
|
|
2375
|
+
class="domain-label"
|
|
2376
|
+
>{@html get_hill_formula(domain.formula, false, ``)}</span>
|
|
2377
|
+
</extras.HTML>
|
|
2378
|
+
{/each}
|
|
2379
|
+
{/if}
|
|
2380
|
+
</ChemPotScene3D>
|
|
2381
|
+
</Canvas>
|
|
2382
|
+
<!-- Color bar for continuous modes -->
|
|
2383
|
+
{#if color_mode !== `none` && color_mode !== `arity` && color_range}
|
|
2384
|
+
{@const color_bar_config = get_chempot_color_bar_config(
|
|
2385
|
+
color_scale,
|
|
2386
|
+
reverse_color_scale,
|
|
2387
|
+
)}
|
|
2388
|
+
<ColorBar
|
|
2389
|
+
title={color_range.label}
|
|
2390
|
+
range={[color_range.min, color_range.max]}
|
|
2391
|
+
color_scale_fn={color_bar_config.color_scale_fn}
|
|
2392
|
+
color_scale_domain={color_bar_config.color_scale_domain}
|
|
2393
|
+
wrapper_style="position: absolute; bottom: 16px; left: 1em; width: 200px; z-index: 10;"
|
|
2394
|
+
bar_style="height: 12px;"
|
|
2395
|
+
title_style="margin-bottom: 4px;"
|
|
2396
|
+
/>
|
|
2397
|
+
{/if}
|
|
2398
|
+
<!-- Categorical legend for arity mode -->
|
|
2399
|
+
{#if color_mode === `arity`}
|
|
2400
|
+
<div class="arity-legend">
|
|
2401
|
+
{#each arity_legend_labels as label, idx (label)}
|
|
2402
|
+
<span>
|
|
2403
|
+
<span style:background={arity_colors[idx]}></span>
|
|
2404
|
+
{label}
|
|
2405
|
+
</span>
|
|
2406
|
+
{/each}
|
|
2407
|
+
</div>
|
|
2408
|
+
{/if}
|
|
2409
|
+
{/if}
|
|
2410
|
+
{#if render_local_tooltip && show_tooltip && hover_info?.view === `3d`}
|
|
2411
|
+
<aside
|
|
2412
|
+
class="phase-tooltip"
|
|
2413
|
+
style:left="{hover_info.pointer?.x ?? 4}px"
|
|
2414
|
+
style:top="{hover_info.pointer?.y ?? 4}px"
|
|
2415
|
+
>
|
|
2416
|
+
<h4>{@html get_hill_formula(hover_info.formula, false, ``)}</h4>
|
|
2417
|
+
{#if locked_hover_formula === hover_info.formula}
|
|
2418
|
+
<p>Pinned · Press Esc to unlock</p>
|
|
2419
|
+
{/if}
|
|
2420
|
+
<p>
|
|
2421
|
+
Vertices: {hover_info.n_vertices} · Edges: {hover_info.n_edges} · Points:
|
|
2422
|
+
{hover_info.n_points}
|
|
2423
|
+
</p>
|
|
2424
|
+
<p>
|
|
2425
|
+
Entries: {hover_info.matching_entry_count}
|
|
2426
|
+
{#if hover_info.min_energy_per_atom !== null &&
|
|
2427
|
+
hover_info.max_energy_per_atom !== null}
|
|
2428
|
+
· E/atom: {format_num(hover_info.min_energy_per_atom, `.4~g`)}
|
|
2429
|
+
to {format_num(hover_info.max_energy_per_atom, `.4~g`)} eV
|
|
2430
|
+
{/if}
|
|
2431
|
+
</p>
|
|
2432
|
+
{#if tooltip_detail_level === `detailed`}
|
|
2433
|
+
<h5 id="axis-ranges">Axis ranges</h5>
|
|
2434
|
+
{#each hover_info.axis_ranges as axis_range (axis_range.element)}
|
|
2435
|
+
<p>
|
|
2436
|
+
{axis_range.element}: {format_num(axis_range.min_val, `.4~g`)} to
|
|
2437
|
+
{format_num(axis_range.max_val, `.4~g`)} eV
|
|
2438
|
+
</p>
|
|
2439
|
+
{/each}
|
|
2440
|
+
<p>
|
|
2441
|
+
Centroid: ({
|
|
2442
|
+
hover_info.ann_loc.map((value) => format_num(value, `.3~g`)).join(
|
|
2443
|
+
`, `,
|
|
2444
|
+
)
|
|
2445
|
+
})
|
|
2446
|
+
</p>
|
|
2447
|
+
{#if hover_info.touches_limits.length > 0}
|
|
2448
|
+
<h5 id="touches-bounds">Touches bounds</h5>
|
|
2449
|
+
<p>{hover_info.touches_limits.join(`, `)}</p>
|
|
2450
|
+
{/if}
|
|
2451
|
+
{/if}
|
|
2452
|
+
</aside>
|
|
2453
|
+
{/if}
|
|
2454
|
+
</div>
|
|
2455
|
+
|
|
2456
|
+
<style>
|
|
2457
|
+
.chempot-diagram-3d {
|
|
2458
|
+
position: relative;
|
|
2459
|
+
overflow: clip;
|
|
2460
|
+
}
|
|
2461
|
+
.chempot-diagram-3d:fullscreen {
|
|
2462
|
+
background: var(--chempot-3d-bg-fullscreen, var(--bg-color, #fff));
|
|
2463
|
+
}
|
|
2464
|
+
/* Threlte <extras.HTML portal={wrapper}> appends absolutely-positioned divs
|
|
2465
|
+
directly to the wrapper. Without pointer-events: none, they intercept mouse
|
|
2466
|
+
events and prevent the Three.js raycaster from detecting hover meshes. */
|
|
2467
|
+
.chempot-diagram-3d > :global(div[style*='position: absolute'][style*='top: 0']) {
|
|
2468
|
+
pointer-events: none !important;
|
|
2469
|
+
}
|
|
2470
|
+
.chempot-diagram-3d > section {
|
|
2471
|
+
position: absolute;
|
|
2472
|
+
top: 1ex;
|
|
2473
|
+
right: 1ex;
|
|
2474
|
+
display: flex;
|
|
2475
|
+
gap: 8px;
|
|
2476
|
+
z-index: 20;
|
|
2477
|
+
}
|
|
2478
|
+
.chempot-diagram-3d > section > :global(button),
|
|
2479
|
+
.chempot-diagram-3d > section > :global(.pane-toggle) {
|
|
2480
|
+
background: transparent;
|
|
2481
|
+
border: none;
|
|
2482
|
+
padding: 4px;
|
|
2483
|
+
cursor: pointer;
|
|
2484
|
+
border-radius: 3px;
|
|
2485
|
+
color: var(--text-color, currentColor);
|
|
2486
|
+
transition: background-color 0.2s;
|
|
2487
|
+
display: flex;
|
|
2488
|
+
font-size: clamp(0.75em, 1.5cqmin, 1em);
|
|
2489
|
+
}
|
|
2490
|
+
.chempot-diagram-3d > section > :global(button:hover),
|
|
2491
|
+
.chempot-diagram-3d > section > :global(.pane-toggle:hover) {
|
|
2492
|
+
background-color: color-mix(in srgb, currentColor 8%, transparent);
|
|
2493
|
+
}
|
|
2494
|
+
.chempot-diagram-3d :global(.chempot-temp-slider) {
|
|
2495
|
+
top: var(--chempot-temp-slider-top, calc(1ex + 108px));
|
|
2496
|
+
right: 4px;
|
|
2497
|
+
z-index: 11;
|
|
2498
|
+
}
|
|
2499
|
+
.chempot-diagram-3d :global(.draggable-pane label) {
|
|
2500
|
+
display: flex;
|
|
2501
|
+
align-items: center;
|
|
2502
|
+
gap: 4pt;
|
|
2503
|
+
font-size: 0.9em;
|
|
2504
|
+
}
|
|
2505
|
+
.chempot-diagram-3d :global(.export-row) {
|
|
2506
|
+
display: flex;
|
|
2507
|
+
flex-wrap: wrap;
|
|
2508
|
+
gap: 4pt 10pt;
|
|
2509
|
+
margin: 0 0 4pt;
|
|
2510
|
+
}
|
|
2511
|
+
.chempot-diagram-3d :global(.export-row > label) {
|
|
2512
|
+
margin: 0;
|
|
2513
|
+
}
|
|
2514
|
+
.chempot-diagram-3d :global(.chempot-checks) {
|
|
2515
|
+
display: flex;
|
|
2516
|
+
flex-wrap: wrap;
|
|
2517
|
+
gap: 1ex;
|
|
2518
|
+
}
|
|
2519
|
+
.chempot-diagram-3d :global(.chempot-nums) {
|
|
2520
|
+
display: flex;
|
|
2521
|
+
flex-wrap: wrap;
|
|
2522
|
+
gap: 1ex;
|
|
2523
|
+
margin: 4pt 0;
|
|
2524
|
+
}
|
|
2525
|
+
.chempot-diagram-3d :global(.projection-controls) {
|
|
2526
|
+
margin: 0 0 6pt;
|
|
2527
|
+
}
|
|
2528
|
+
.chempot-diagram-3d :global(.projection-controls .pane-row) {
|
|
2529
|
+
display: grid;
|
|
2530
|
+
grid-template-columns:
|
|
2531
|
+
auto minmax(4.5em, 1fr) auto minmax(4.5em, 1fr) auto minmax(4.5em, 1fr);
|
|
2532
|
+
align-items: center;
|
|
2533
|
+
gap: 3pt;
|
|
2534
|
+
}
|
|
2535
|
+
.chempot-diagram-3d :global(.projection-presets) {
|
|
2536
|
+
margin-top: 4pt;
|
|
2537
|
+
display: flex;
|
|
2538
|
+
flex-wrap: wrap;
|
|
2539
|
+
gap: 4pt;
|
|
2540
|
+
}
|
|
2541
|
+
.chempot-diagram-3d :global(.projection-presets button) {
|
|
2542
|
+
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
|
2543
|
+
border-radius: 3px;
|
|
2544
|
+
padding: 1px 5px;
|
|
2545
|
+
background: transparent;
|
|
2546
|
+
cursor: pointer;
|
|
2547
|
+
font-size: 0.85em;
|
|
2548
|
+
color: var(--text-color, currentColor);
|
|
2549
|
+
}
|
|
2550
|
+
.chempot-diagram-3d :global(.projection-presets button.selected) {
|
|
2551
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
2552
|
+
}
|
|
2553
|
+
.chempot-diagram-3d :global(.overlay-actions) {
|
|
2554
|
+
display: flex;
|
|
2555
|
+
gap: 4pt;
|
|
2556
|
+
margin: 0 0 4pt;
|
|
2557
|
+
}
|
|
2558
|
+
.chempot-diagram-3d :global(.overlay-actions button) {
|
|
2559
|
+
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
|
2560
|
+
border-radius: 3px;
|
|
2561
|
+
padding: 2px 6px;
|
|
2562
|
+
background: transparent;
|
|
2563
|
+
cursor: pointer;
|
|
2564
|
+
color: var(--text-color, currentColor);
|
|
2565
|
+
}
|
|
2566
|
+
.chempot-diagram-3d :global(.overlay-search) {
|
|
2567
|
+
display: flex;
|
|
2568
|
+
align-items: center;
|
|
2569
|
+
gap: 4pt;
|
|
2570
|
+
margin: 0 0 4pt;
|
|
2571
|
+
}
|
|
2572
|
+
.chempot-diagram-3d :global(.overlay-search input) {
|
|
2573
|
+
width: 100%;
|
|
2574
|
+
min-width: 10em;
|
|
2575
|
+
}
|
|
2576
|
+
.chempot-diagram-3d :global(.formula-list) {
|
|
2577
|
+
max-height: min(42vh, 18rem);
|
|
2578
|
+
overflow: auto;
|
|
2579
|
+
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
|
2580
|
+
border-radius: 4px;
|
|
2581
|
+
padding: 4pt;
|
|
2582
|
+
}
|
|
2583
|
+
.chempot-diagram-3d :global(.formula-list label) {
|
|
2584
|
+
display: flex;
|
|
2585
|
+
align-items: center;
|
|
2586
|
+
gap: 5pt;
|
|
2587
|
+
margin: 2pt 0;
|
|
2588
|
+
}
|
|
2589
|
+
.chempot-diagram-3d :global(.formula-color-dot) {
|
|
2590
|
+
width: 0.65em;
|
|
2591
|
+
height: 0.65em;
|
|
2592
|
+
border-radius: 50%;
|
|
2593
|
+
flex-shrink: 0;
|
|
2594
|
+
}
|
|
2595
|
+
.chempot-diagram-3d :global(.formula-empty) {
|
|
2596
|
+
font-size: 0.9em;
|
|
2597
|
+
opacity: 0.7;
|
|
2598
|
+
}
|
|
2599
|
+
.chempot-diagram-3d :global(.chempot-nums input[type='number']) {
|
|
2600
|
+
width: 5em;
|
|
2601
|
+
}
|
|
2602
|
+
.chempot-diagram-3d :global(.draggable-pane select) {
|
|
2603
|
+
flex: 1;
|
|
2604
|
+
min-width: 0;
|
|
2605
|
+
padding: 2px 4px;
|
|
2606
|
+
}
|
|
2607
|
+
.error-state {
|
|
2608
|
+
display: flex;
|
|
2609
|
+
flex-direction: column;
|
|
2610
|
+
align-items: center;
|
|
2611
|
+
justify-content: center;
|
|
2612
|
+
height: 100%;
|
|
2613
|
+
color: var(--text-color, #666);
|
|
2614
|
+
}
|
|
2615
|
+
:is(.axis-label, .tick-label) {
|
|
2616
|
+
pointer-events: none;
|
|
2617
|
+
user-select: none;
|
|
2618
|
+
white-space: nowrap;
|
|
2619
|
+
}
|
|
2620
|
+
.axis-label {
|
|
2621
|
+
font: bold 13px sans-serif;
|
|
2622
|
+
}
|
|
2623
|
+
.tick-label {
|
|
2624
|
+
font-size: 10px;
|
|
2625
|
+
color: var(--text-color, #333);
|
|
2626
|
+
}
|
|
2627
|
+
.domain-label {
|
|
2628
|
+
font: 11px sans-serif;
|
|
2629
|
+
color: var(--text-color, #333);
|
|
2630
|
+
opacity: 0.7;
|
|
2631
|
+
white-space: nowrap;
|
|
2632
|
+
pointer-events: none;
|
|
2633
|
+
}
|
|
2634
|
+
.phase-tooltip {
|
|
2635
|
+
position: fixed;
|
|
2636
|
+
max-width: min(32rem, 92vw);
|
|
2637
|
+
background: var(
|
|
2638
|
+
--tooltip-bg,
|
|
2639
|
+
light-dark(rgba(255, 255, 255, 0.95), rgba(0, 0, 0, 0.9))
|
|
2640
|
+
);
|
|
2641
|
+
color: var(--tooltip-text, var(--text-color, #222));
|
|
2642
|
+
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
|
2643
|
+
border-radius: 6px;
|
|
2644
|
+
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
|
|
2645
|
+
padding: 8px 10px;
|
|
2646
|
+
font-size: 12px;
|
|
2647
|
+
line-height: 1.35;
|
|
2648
|
+
pointer-events: none;
|
|
2649
|
+
z-index: 100;
|
|
2650
|
+
}
|
|
2651
|
+
.phase-tooltip h4 {
|
|
2652
|
+
margin: 0 0 4px;
|
|
2653
|
+
font-size: 13px;
|
|
2654
|
+
}
|
|
2655
|
+
.phase-tooltip p {
|
|
2656
|
+
margin: 1px 0;
|
|
2657
|
+
white-space: nowrap;
|
|
2658
|
+
overflow: hidden;
|
|
2659
|
+
text-overflow: ellipsis;
|
|
2660
|
+
}
|
|
2661
|
+
.phase-tooltip h5 {
|
|
2662
|
+
margin-top: 6px;
|
|
2663
|
+
margin-bottom: 0;
|
|
2664
|
+
font-size: 12px;
|
|
2665
|
+
font-weight: 600;
|
|
2666
|
+
}
|
|
2667
|
+
.arity-legend {
|
|
2668
|
+
position: absolute;
|
|
2669
|
+
bottom: 16px;
|
|
2670
|
+
left: 1em;
|
|
2671
|
+
display: flex;
|
|
2672
|
+
gap: 10px;
|
|
2673
|
+
font-size: 12px;
|
|
2674
|
+
z-index: 10;
|
|
2675
|
+
pointer-events: none;
|
|
2676
|
+
}
|
|
2677
|
+
.arity-legend > span {
|
|
2678
|
+
display: flex;
|
|
2679
|
+
align-items: center;
|
|
2680
|
+
gap: 4px;
|
|
2681
|
+
}
|
|
2682
|
+
.arity-legend > span > span {
|
|
2683
|
+
width: 10px;
|
|
2684
|
+
height: 10px;
|
|
2685
|
+
border-radius: 50%;
|
|
2686
|
+
flex-shrink: 0;
|
|
2687
|
+
}
|
|
2688
|
+
</style>
|