mirror of
https://github.com/mprojectscode/obsidian-bases-charts-plugin.git
synced 2026-07-22 06:43:19 +00:00
refactor; update readme
This commit is contained in:
parent
f187d41ded
commit
1fa3693fc5
9 changed files with 223 additions and 209 deletions
21
README.md
21
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ Low: 72.321426
|
|||
Close: 73.227142
|
||||
Adj Close: 62.759319
|
||||
Volume: 82086200
|
||||
---
|
||||
---
|
||||
|
|
|
|||
146
packages/obsidian/src/ChartData.ts
Normal file
146
packages/obsidian/src/ChartData.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue