refactor; update readme

This commit is contained in:
Moritz Jung 2025-10-08 13:22:23 +02:00
parent f187d41ded
commit 1fa3693fc5
9 changed files with 223 additions and 209 deletions

View file

@ -5,9 +5,24 @@ This plugin for Obsidian adds three new bases views: Scatter Charts, Line Charts
## Usage
First you need a [Base](https://help.obsidian.md/bases), then from there you can (with the plugin installed and enabled) create thee new bases views: Scatter Charts, Line Charts, and Bar Charts.
Next you need to select the properties or formulas used for the X and Y values. Currently the plugin only supports a 1-to-1 mapping of notes to data points.
On the X axis, [`Number`, `Date`, and `String`](https://help.obsidian.md/bases/functions) are supported. On the Y axis the plugin only supports [`Number`](https://help.obsidian.md/bases/functions).
Data points can be grouped into multiple colors via a `Group by` sort.
Next you need to select the property or formula used for the X axis, in the view settings.
On the X axis, [`Number`, `Date`, and `String`](https://help.obsidian.md/bases/functions) are supported.
Then you can select which properties to display on the Y Axis using the Base's `Properties` menu (top right).
It is recommended that you disable the default activated file name.
On the Y axis the plugin only supports values of type [`Number`](https://help.obsidian.md/bases/functions).
### Grouping and Multiple Charts
The plugin supports grouping data points by color and spliting them into multiple charts.
The view settings (in the Base's view menu, top left) include a `Multi chart mode` dropdown.
The avaiable options are `Separate by group` and `Separate by property`.
Separating by group will use a `Group by` sort (Base's `Sort` menu, top right) to arrange the notes into multiple charts.
Here, every group gets it's own chart. Within one chart, data points are colored by their Y axis property.
Separating by property will display a separate chart for each selected Y axis property.
Within a chart, data points are colored using a `Group by` sort (Base's `Sort` menu, top right).
### Scatter Charts

View file

@ -6,4 +6,4 @@ Low: 72.321426
Close: 73.227142
Adj Close: 62.759319
Volume: 82086200
---
---

View file

@ -0,0 +1,146 @@
import type { BasesPropertyId } from 'obsidian';
import type { ChartView } from 'packages/obsidian/src/ChartView';
import { OBSIDIAN_DEFAULT_SINGLE_COLOR, OBSIDIAN_COLOR_PALETTE } from 'packages/obsidian/src/utils/utils';
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ProcessedData = {
x: number | Date | string;
y: number;
/**
* This is always the value by which we color the data point.
* In group separated mode, this is the display name of the property.
* In property separated mode, this is the group by value.
*/
groupIndex: number;
/**
* This is always the chart to sort the data point into.
* In group separated mode, this is the group by value.
* In property separated mode, this is the property id.
*/
chartIndex: number;
file: string;
};
export abstract class AbstractDataWrapper<ChartId, GroupId> {
readonly view: ChartView;
readonly data: ProcessedData[];
readonly groupBySet: string[];
constructor(view: ChartView, data: ProcessedData[], groupBySet: string[]) {
this.view = view;
this.data = data;
this.groupBySet = groupBySet;
}
abstract getChartIdentifiers(): ChartId[];
abstract getGroupIdentifiers(): GroupId[];
abstract getChartName(chartIndex: number): string;
abstract getGroupName(groupIndex: number): string;
getChartGroupIdentifier(): (d: ProcessedData) => string {
if (this.hasMultipleGroups()) {
return d => this.getColorFromGroupIndex(d.groupIndex);
}
return OBSIDIAN_DEFAULT_SINGLE_COLOR;
}
getColorFromGroupIndex(groupIndex: number): string {
return OBSIDIAN_COLOR_PALETTE[groupIndex % OBSIDIAN_COLOR_PALETTE.length];
}
hasMultipleGroups(): boolean {
return this.getGroupIdentifiers().length > 1;
}
hasMultipleCharts(): boolean {
return this.getChartIdentifiers().length > 1;
}
getFlat(chartIndex: number, sorted: boolean = false): ProcessedData[] {
const data = this.data.filter(d => d.chartIndex === chartIndex);
if (sorted) {
return sortDataByGroup(data);
}
return data;
}
getStacked(chartIndex: number): ProcessedData[] {
const xMap = new Map<number | Date | string, number>();
const stackedData: ProcessedData[] = [];
for (const entry of this.data) {
if (entry.chartIndex !== chartIndex) {
continue;
}
const prevY = xMap.get(entry.x) ?? 0;
const newY = prevY + entry.y;
stackedData.push({
...entry,
y: newY,
});
xMap.set(entry.x, newY);
}
return stackedData;
}
}
export class GroupSeparatedData extends AbstractDataWrapper<string, BasesPropertyId> {
getChartIdentifiers(): string[] {
return this.groupBySet;
}
getGroupIdentifiers(): BasesPropertyId[] {
return this.view.data.properties;
}
getChartName(chartIndex: number): string {
return this.getChartIdentifiers()[chartIndex] ?? `Chart ${chartIndex + 1}`;
}
getGroupName(groupIndex: number): string {
const groupId = this.getGroupIdentifiers()[groupIndex];
return this.view.config.getDisplayName(groupId) ?? `Group ${groupIndex + 1}`;
}
}
export class PropertySeparatedData extends AbstractDataWrapper<BasesPropertyId, string> {
getChartIdentifiers(): BasesPropertyId[] {
return this.view.data.properties;
}
getGroupIdentifiers(): string[] {
return this.groupBySet;
}
getChartName(chartIndex: number): string {
const chartId = this.getChartIdentifiers()[chartIndex];
return this.view.config.getDisplayName(chartId) ?? `Chart ${chartIndex + 1}`;
}
getGroupName(groupIndex: number): string {
return this.getGroupIdentifiers()[groupIndex] ?? `Group ${groupIndex + 1}`;
}
}
export type DataWrapper = GroupSeparatedData | PropertySeparatedData;
export function emptyDataWrapper(view: ChartView): DataWrapper {
return new GroupSeparatedData(view, [], []);
}
export function sortDataByGroup(data: ProcessedData[]): ProcessedData[] {
return data.sort((a, b) => {
if (a.groupIndex != null && b.groupIndex != null) {
return a.groupIndex - b.groupIndex;
}
return 0;
});
}

View file

@ -1,10 +1,12 @@
import type { BasesEntry, QueryController, Value } from 'obsidian';
import type { BasesEntry, QueryController } from 'obsidian';
import type { BasesPropertyId, ViewOption } from 'obsidian';
import { BasesView, DateValue, Events, NumberValue, StringValue } from 'obsidian';
import { BasesView, Events } from 'obsidian';
import type { DataWrapper, ProcessedData } from 'packages/obsidian/src/ChartData';
import { emptyDataWrapper, GroupSeparatedData, PropertySeparatedData } from 'packages/obsidian/src/ChartData';
import BarPlot from 'packages/obsidian/src/charts/BarPlot.svelte';
import LinePlot from 'packages/obsidian/src/charts/LinePlot.svelte';
import ScatterPlot from 'packages/obsidian/src/charts/ScatterPlot.svelte';
import { OBSIDIAN_COLOR_PALETTE, OBSIDIAN_DEFAULT_SINGLE_COLOR } from 'packages/obsidian/src/utils/utils';
import { parseValueAsNumber, parseValueAsX } from 'packages/obsidian/src/utils/utils';
import { mount, unmount } from 'svelte';
export const SCATTER_CHART_VIEW_TYPE = 'chart-scatter';
@ -20,202 +22,11 @@ export const CHART_SETTINGS = {
MULTI_CHART: 'multi-chart-mode',
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ProcessedData = {
x: number | Date | string;
y: number;
/**
* This is always the value by which we color the data point.
* In group separated mode, this is the display name of the property.
* In property separated mode, this is the group by value.
*/
groupIndex: number;
/**
* This is always the chart to sort the data point into.
* In group separated mode, this is the group by value.
* In property separated mode, this is the property id.
*/
chartIndex: number;
file: string;
};
export interface ProcessedGroupEntry {
key: string;
entries: ProcessedData[];
}
export type ProcessedGroupedData =
| {
grouped: true;
groups: ProcessedGroupEntry[];
}
| {
grouped: false;
entries: ProcessedData[];
};
export enum MultiChartMode {
GROUP = 'Separate by group',
PROPERTY = 'Separate by property',
}
function sortDataByGroup(data: ProcessedData[]): ProcessedData[] {
return data.sort((a, b) => {
if (a.groupIndex != null && b.groupIndex != null) {
return a.groupIndex - b.groupIndex;
}
return 0;
});
}
export abstract class AbstractDataWrapper<ChartId, GroupId> {
readonly view: ChartView;
readonly data: ProcessedData[];
readonly groupBySet: string[];
constructor(view: ChartView, data: ProcessedData[], groupBySet: string[]) {
this.view = view;
this.data = data;
this.groupBySet = groupBySet;
}
abstract getChartIdentifiers(): ChartId[];
abstract getGroupIdentifiers(): GroupId[];
abstract getChartName(chartIndex: number): string;
abstract getGroupName(groupIndex: number): string;
getChartGroupIdentifier(): (d: ProcessedData) => string {
if (this.hasMultipleGroups()) {
return d => this.getColorFromGroupIndex(d.groupIndex);
}
return OBSIDIAN_DEFAULT_SINGLE_COLOR;
}
getColorFromGroupIndex(groupIndex: number): string {
return OBSIDIAN_COLOR_PALETTE[groupIndex % OBSIDIAN_COLOR_PALETTE.length];
}
hasMultipleGroups(): boolean {
return this.getGroupIdentifiers().length > 1;
}
hasMultipleCharts(): boolean {
return this.getChartIdentifiers().length > 1;
}
getFlat(chartIndex: number, sorted: boolean = false): ProcessedData[] {
const data = this.data.filter(d => d.chartIndex === chartIndex);
if (sorted) {
return sortDataByGroup(data);
}
return data;
}
getStacked(chartIndex: number): ProcessedData[] {
const xMap = new Map<number | Date | string, number>();
const stackedData: ProcessedData[] = [];
for (const entry of this.data) {
if (entry.chartIndex !== chartIndex) {
continue;
}
const prevY = xMap.get(entry.x) ?? 0;
const newY = prevY + entry.y;
stackedData.push({
...entry,
y: newY,
});
xMap.set(entry.x, newY);
}
return stackedData;
}
}
export class GroupSeparatedData extends AbstractDataWrapper<string, BasesPropertyId> {
getChartIdentifiers(): string[] {
return this.groupBySet;
}
getGroupIdentifiers(): BasesPropertyId[] {
return this.view.data.properties;
}
getChartName(chartIndex: number): string {
return this.getChartIdentifiers()[chartIndex] ?? `Chart ${chartIndex + 1}`;
}
getGroupName(groupIndex: number): string {
const groupId = this.getGroupIdentifiers()[groupIndex];
return this.view.config.getDisplayName(groupId) ?? `Group ${groupIndex + 1}`;
}
}
export class PropertySeparatedData extends AbstractDataWrapper<BasesPropertyId, string> {
getChartIdentifiers(): BasesPropertyId[] {
return this.view.data.properties;
}
getGroupIdentifiers(): string[] {
return this.groupBySet;
}
getChartName(chartIndex: number): string {
const chartId = this.getChartIdentifiers()[chartIndex];
return this.view.config.getDisplayName(chartId) ?? `Chart ${chartIndex + 1}`;
}
getGroupName(groupIndex: number): string {
return this.getGroupIdentifiers()[groupIndex] ?? `Group ${groupIndex + 1}`;
}
}
export type DataWrapper = GroupSeparatedData | PropertySeparatedData;
export function emptyDataWrapper(view: ChartView): DataWrapper {
return new GroupSeparatedData(view, [], []);
}
export function parseValueAsNumber(value: Value | null): number | null {
if (!value) {
return null;
}
if (value instanceof NumberValue) {
return value.data;
}
if (value instanceof StringValue) {
const parsed = parseFloat(value.data);
return isNaN(parsed) ? null : parsed;
}
return null;
}
export function parseValueAsX(value: Value | null): number | Date | string | null {
if (!value) {
return null;
}
if (value instanceof NumberValue) {
return value.data;
}
if (value instanceof StringValue) {
const parsed = parseFloat(value.data);
return isNaN(parsed) ? value.data : parsed;
}
if (value instanceof DateValue) {
return new Date(value.toString());
}
return null;
}
export class ChartView extends BasesView {
readonly type: ChartViewType;
readonly scrollEl: HTMLElement;

View file

@ -2,7 +2,7 @@
import { AxisX, AxisY, BarY, GridY, Plot, Text } from 'svelteplot';
import { CHART_SETTINGS, ChartView } from '../ChartView';
import { onMount } from 'svelte';
import { OBSIDIAN_COLOR_PALETTE, toCompactString } from '../utils/utils';
import { toCompactString } from '../utils/utils';
import PlotGrid from './PlotGrid.svelte';
interface Props {

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { Line, Plot, GridX, GridY, AxisY, AxisX, Pointer, Text, Dot, RuleX } from 'svelteplot';
import { ChartView } from '../ChartView';
import { OBSIDIAN_COLOR_PALETTE, toCompactString } from '../utils/utils';
import { toCompactString } from '../utils/utils';
import PlotGrid from './PlotGrid.svelte';
interface Props {

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { AbstractDataWrapper, CHART_SETTINGS, type ChartView, type DataWrapper } from '../ChartView';
import { CHART_SETTINGS, type ChartView } from '../ChartView';
import { OBSIDIAN_DEFAULT_SINGLE_COLOR, type FullChartProps } from '../utils/utils';
import PlotGridItem from './PlotGridItem.svelte';
import type { DataWrapper } from '../ChartData';
interface Props {
view: ChartView;

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ChartProps, FullChartProps } from '../utils/utils';
import type { ChartView, ProcessedData } from '../ChartView';
import type { ChartView } from '../ChartView';
import type { ProcessedData } from '../ChartData';
interface Props {
view: ChartView;
@ -13,6 +14,7 @@
let width = $state(0);
let height = $state(0);
let enoughSpace = $derived(width > 100 && height > 100);
let hoveredData: ProcessedData[] = $state([]);
@ -34,12 +36,16 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bases-charts-plot-grid-item" bind:clientWidth={width} bind:clientHeight={height} onclick={onClick}>
{@render chartSnippet({
...chartProps,
width,
height,
setHoveredData,
})}
{#if enoughSpace}
{@render chartSnippet({
...chartProps,
width,
height,
setHoveredData,
})}
{:else}
<span>Not enough space to display chart.</span>
{/if}
</div>
<style>

View file

@ -1,4 +1,6 @@
import type { AbstractDataWrapper, ProcessedData } from 'packages/obsidian/src/ChartView';
import type { Value } from 'obsidian';
import { DateValue, NumberValue, StringValue } from 'obsidian';
import type { AbstractDataWrapper, ProcessedData } from 'packages/obsidian/src/ChartData';
export function toCompactString(datum: number | string | symbol | boolean | Date | null | undefined): string {
if (datum == null) {
@ -45,3 +47,36 @@ export type FullChartProps<ChartId, GroupId> = ChartProps<ChartId, GroupId> & {
height: number;
setHoveredData: (data: ProcessedData[]) => void;
};
export function parseValueAsNumber(value: Value | null): number | null {
if (!value) {
return null;
}
if (value instanceof NumberValue) {
return value.data;
}
if (value instanceof StringValue) {
const parsed = parseFloat(value.data);
return isNaN(parsed) ? null : parsed;
}
return null;
}
export function parseValueAsX(value: Value | null): number | Date | string | null {
if (!value) {
return null;
}
if (value instanceof NumberValue) {
return value.data;
}
if (value instanceof StringValue) {
const parsed = parseFloat(value.data);
return isNaN(parsed) ? value.data : parsed;
}
if (value instanceof DateValue) {
return new Date(value.toString());
}
return null;
}