commit 0be88d7908197ffcf0be8848cbbe096016e1529e Author: Elias <36868565+Prodigist@users.noreply.github.com> Date: Sat Feb 1 09:31:17 2025 +0000 Initial commit: ReactiveNotes plugin diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..81f3ec3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = 4 +tab_width = 4 diff --git a/.env b/.env new file mode 100644 index 0000000..edde2fa --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +// .env file (Do NOT commit this file) +ALPHA_VANTAGE_PRIMARY_KEY=7NMNWUG3QGNSK9J2 +ALPHA_VANTAGE_SECONDARY_KEY=9H7UI9KW24V8UXCW +MARKET_DATA_CACHE_DURATION=86400000 +TIMEZONE=US/Eastern + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f249cda --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +// .env.example file +ALPHA_VANTAGE_PRIMARY_KEY=your_primary_key_here +ALPHA_VANTAGE_SECONDARY_KEY=your_secondary_key_here +MARKET_DATA_CACHE_DURATION=86400000 +TIMEZONE=US/Eastern \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e019f3c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules/ + +main.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..0807290 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,23 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "env": { "node": true }, + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], + "@typescript-eslint/ban-ts-comment": "off", + "no-prototype-builtins": "off", + "@typescript-eslint/no-empty-function": "off" + } + } \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c1cda3 Binary files /dev/null and b/.gitignore differ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b973752 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +tag-version-prefix="" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c773152 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Obsidian Sample Plugin + +This is a sample plugin for Obsidian (https://obsidian.md). + +This project uses TypeScript to provide type checking and documentation. +The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. + +This sample plugin demonstrates some of the basic functionality the plugin API can do. +- Adds a ribbon icon, which shows a Notice when clicked. +- Adds a command "Open Sample Modal" which opens a Modal. +- Adds a plugin setting tab to the settings page. +- Registers a global click event and output 'click' to the console. +- Registers a global interval which logs 'setInterval' to the console. + +## First time developing plugins? + +Quick starting guide for new plugin devs: + +- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. +- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). +- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. +- Install NodeJS, then run `npm i` in the command line under your repo folder. +- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. +- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. +- Reload Obsidian to load the new version of your plugin. +- Enable plugin in settings window. +- For updates to the Obsidian API run `npm update` in the command line under your repo folder. + +## Releasing new releases + +- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. +- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. +- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases +- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. +- Publish the release. + +> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. +> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` + +## Adding your plugin to the community plugin list + +- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). +- Publish an initial version. +- Make sure you have a `README.md` file in the root of your repo. +- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. + +## How to use + +- Clone this repo. +- Make sure your NodeJS is at least v16 (`node --version`). +- `npm i` or `yarn` to install dependencies. +- `npm run dev` to start compilation in watch mode. + +## Manually installing the plugin + +- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. + +## Improve code quality with eslint (optional) +- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. +- To use eslint with this project, make sure to install eslint from terminal: + - `npm install -g eslint` +- To use eslint to analyze this project use this command: + - `eslint main.ts` + - eslint will then create a report with suggestions for code improvement by file and line number. +- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: + - `eslint .\src\` + +## Funding URL + +You can include funding URLs where people who use your plugin can financially support it. + +The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: + +```json +{ + "fundingUrl": "https://buymeacoffee.com" +} +``` + +If you have multiple URLs, you can also do: + +```json +{ + "fundingUrl": { + "Buy Me a Coffee": "https://buymeacoffee.com", + "GitHub Sponsor": "https://github.com/sponsors", + "Patreon": "https://www.patreon.com/" + } +} +``` + +## API Documentation + +See https://github.com/obsidianmd/obsidian-api diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..596aeaf --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,66 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["main.tsx"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2020", + platform: "node", // Add this for Node.js compatibility + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, + jsx: "transform", // Add this line + loader: { // Add loader configuration + '.tsx': 'tsx', + '.ts': 'ts', + '.jsx': 'jsx', + '.js': 'js', + '.css': 'css', // Add CSS loader + '.json': 'json' // Add JSON loader for Tailwind config + }, + define: { // Add environment definitions + 'process.env.NODE_ENV': prod ? '"production"' : '"development"', + global: 'window' + }, + inject: [ // Add React shim + './react-shim.js' + ], +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/lwc-plugin-custom-primitives/.gitignore b/lwc-plugin-custom-primitives/.gitignore new file mode 100644 index 0000000..a90cc13 --- /dev/null +++ b/lwc-plugin-custom-primitives/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +typings +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/lwc-plugin-custom-primitives/README.md b/lwc-plugin-custom-primitives/README.md new file mode 100644 index 0000000..211250c --- /dev/null +++ b/lwc-plugin-custom-primitives/README.md @@ -0,0 +1,38 @@ +# Custom Primitives - Lightweight Charts™ Plugin + +Description of the Plugin. + +- Developed for Lightweight Charts version: `v4.1.0` + +## Running Locally + +```shell +npm install +npm run dev +``` + +Visit `localhost:5173` in the browser. + +## Compiling + +```shell +npm run compile +``` + +Check the output in the `dist` folder. + +## Publishing To NPM + +You can configure the contents of the package's `package.json` within the +`compile.mjs` script. + +Once you have compiled the plugin (see above section) then you can publish the +package to NPM with these commands: + +```shell +cd dist +npm publish +``` + +Hint: append `--dry-run` to the end of the publish command to see the results of +the publish command without actually uploading the package to NPM. diff --git a/lwc-plugin-custom-primitives/compile.mjs b/lwc-plugin-custom-primitives/compile.mjs new file mode 100644 index 0000000..ffc34b5 --- /dev/null +++ b/lwc-plugin-custom-primitives/compile.mjs @@ -0,0 +1,116 @@ +import { dirname, resolve } from 'node:path'; +import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { build, defineConfig } from 'vite'; +import { fileURLToPath } from 'url'; +import { generateDtsBundle } from 'dts-bundle-generator'; + +function buildPackageJson(packageName) { + /* + Define the contents of the package's package.json here. + */ + return { + name: packageName, + version: '1.0.0', + keywords: ['lwc-plugin', 'lightweight-charts'], + type: 'module', + main: `./${packageName}.umd.cjs`, + module: `./${packageName}.js`, + types: `./${packageName}.d.ts`, + exports: { + import: { + types: `./${packageName}.d.ts`, + default: `./${packageName}.js`, + }, + require: { + types: `./${packageName}.d.cts`, + default: `./${packageName}.umd.cjs`, + }, + }, + }; +} + +const __filename = fileURLToPath(import.meta.url); +const currentDir = dirname(__filename); + +const pluginFileName = 'custom-primitives'; +const pluginFile = resolve(currentDir, 'src', `${pluginFileName}.ts`); + +const pluginsToBuild = [ + { + filepath: pluginFile, + exportName: 'lwc-plugin-custom-primitives', + name: 'CustomPrimitives', + }, +]; + +const compiledFolder = resolve(currentDir, 'dist'); +if (!existsSync(compiledFolder)) { + mkdirSync(compiledFolder); +} + +const buildConfig = ({ + filepath, + name, + exportName, + formats = ['es', 'umd'], +}) => { + return defineConfig({ + publicDir: false, + build: { + outDir: `dist`, + emptyOutDir: true, + copyPublicDir: false, + lib: { + entry: filepath, + name, + formats, + fileName: exportName, + }, + rollupOptions: { + external: ['lightweight-charts', 'fancy-canvas'], + output: { + globals: { + 'lightweight-charts': 'LightweightCharts', + }, + }, + }, + }, + }); +}; + +const startTime = Date.now().valueOf(); +console.log('⚡️ Starting'); +console.log('Bundling the plugin...'); +const promises = pluginsToBuild.map(file => { + return build(buildConfig(file)); +}); +await Promise.all(promises); +console.log('Generating the package.json file...'); +pluginsToBuild.forEach(file => { + const packagePath = resolve(compiledFolder, 'package.json'); + const content = JSON.stringify( + buildPackageJson(file.exportName), + undefined, + 4 + ); + writeFileSync(packagePath, content, { encoding: 'utf-8' }); +}); +console.log('Generating the typings files...'); +pluginsToBuild.forEach(file => { + try { + const esModuleTyping = generateDtsBundle([ + { + filePath: `./typings/${pluginFileName}.d.ts`, + }, + ]); + const typingFilePath = resolve(compiledFolder, `${file.exportName}.d.ts`); + writeFileSync(typingFilePath, esModuleTyping.join('\n'), { + encoding: 'utf-8', + }); + copyFileSync(typingFilePath, resolve(compiledFolder, `${file.exportName}.d.cts`)); + } catch (e) { + console.error('Error generating typings for: ', file.exportName); + } +}); +const endTime = Date.now().valueOf(); +console.log(`🎉 Done (${endTime - startTime}ms)`); diff --git a/lwc-plugin-custom-primitives/index.html b/lwc-plugin-custom-primitives/index.html new file mode 100644 index 0000000..3f1b3a1 --- /dev/null +++ b/lwc-plugin-custom-primitives/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/lwc-plugin-custom-primitives/package.json b/lwc-plugin-custom-primitives/package.json new file mode 100644 index 0000000..6bf8786 --- /dev/null +++ b/lwc-plugin-custom-primitives/package.json @@ -0,0 +1,17 @@ +{ + "name": "lwc-plugin-custom-primitives", + "type": "module", + "scripts": { + "dev": "vite --config src/vite.config.js", + "compile": "tsc && node compile.mjs" + }, + "devDependencies": { + "typescript": "^5.0.4", + "vite": "^4.3.1" + }, + "dependencies": { + "dts-bundle-generator": "^8.0.1", + "fancy-canvas": "^2.1.0", + "lightweight-charts": "^4.1.0-rc2" + } +} diff --git a/lwc-plugin-custom-primitives/src/axis-pane-renderer.ts b/lwc-plugin-custom-primitives/src/axis-pane-renderer.ts new file mode 100644 index 0000000..49380b2 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/axis-pane-renderer.ts @@ -0,0 +1,41 @@ +import { CanvasRenderingTarget2D } from 'fancy-canvas'; +import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts'; +import { positionsBox } from './helpers/dimensions/positions'; + +export class CustomPrimitivesAxisPaneRenderer implements ISeriesPrimitivePaneRenderer { + _p1: number | null; + _p2: number | null; + _fillColor: string; + _vertical: boolean = false; + + constructor( + p1: number | null, + p2: number | null, + fillColor: string, + vertical: boolean + ) { + this._p1 = p1; + this._p2 = p2; + this._fillColor = fillColor; + this._vertical = vertical; + } + + draw(target: CanvasRenderingTarget2D) { + target.useBitmapCoordinateSpace(scope => { + if (this._p1 === null || this._p2 === null) return; + const ctx = scope.context; + ctx.globalAlpha = 0.5; + const positions = positionsBox( + this._p1, + this._p2, + this._vertical ? scope.verticalPixelRatio : scope.horizontalPixelRatio + ); + ctx.fillStyle = this._fillColor; + if (this._vertical) { + ctx.fillRect(0, positions.position, 15, positions.length); + } else { + ctx.fillRect(positions.position, 0, positions.length, 15); + } + }); + } +} diff --git a/lwc-plugin-custom-primitives/src/axis-pane-view.ts b/lwc-plugin-custom-primitives/src/axis-pane-view.ts new file mode 100644 index 0000000..a040e98 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/axis-pane-view.ts @@ -0,0 +1,55 @@ +import { + Coordinate, + ISeriesPrimitivePaneView, + SeriesPrimitivePaneViewZOrder, +} from 'lightweight-charts'; +import { CustomPrimitivesAxisPaneRenderer } from './axis-pane-renderer'; +import { CustomPrimitivesDataSource } from './data-source'; + +abstract class CustomPrimitivesAxisPaneView implements ISeriesPrimitivePaneView { + _source: CustomPrimitivesDataSource; + _p1: number | null = null; + _p2: number | null = null; + _vertical: boolean = false; + + constructor(source: CustomPrimitivesDataSource, vertical: boolean) { + this._source = source; + this._vertical = vertical; + } + + abstract getPoints(): [Coordinate | null, Coordinate | null]; + + update() { + [this._p1, this._p2] = this.getPoints(); + } + + renderer() { + return new CustomPrimitivesAxisPaneRenderer( + this._p1, + this._p2, + this._source.options.fillColor, + this._vertical + ); + } + zOrder(): SeriesPrimitivePaneViewZOrder { + return 'bottom'; + } +} + +export class CustomPrimitivesPriceAxisPaneView extends CustomPrimitivesAxisPaneView { + getPoints(): [Coordinate | null, Coordinate | null] { + const series = this._source.series; + const y1 = series.priceToCoordinate(this._source.p1.price); + const y2 = series.priceToCoordinate(this._source.p2.price); + return [y1, y2]; + } +} + +export class CustomPrimitivesTimeAxisPaneView extends CustomPrimitivesAxisPaneView { + getPoints(): [Coordinate | null, Coordinate | null] { + const timeScale = this._source.chart.timeScale(); + const x1 = timeScale.timeToCoordinate(this._source.p1.time); + const x2 = timeScale.timeToCoordinate(this._source.p2.time); + return [x1, x2]; + } +} diff --git a/lwc-plugin-custom-primitives/src/axis-view.ts b/lwc-plugin-custom-primitives/src/axis-view.ts new file mode 100644 index 0000000..435f73f --- /dev/null +++ b/lwc-plugin-custom-primitives/src/axis-view.ts @@ -0,0 +1,57 @@ +import { Coordinate, ISeriesPrimitiveAxisView } from 'lightweight-charts'; +import { Point, CustomPrimitivesDataSource } from './data-source'; + +abstract class CustomPrimitivesAxisView implements ISeriesPrimitiveAxisView { + _source: CustomPrimitivesDataSource; + _p: Point; + _pos: Coordinate | null = null; + constructor(source: CustomPrimitivesDataSource, p: Point) { + this._source = source; + this._p = p; + } + abstract update(): void; + abstract text(): string; + + coordinate() { + return this._pos ?? -1; + } + + visible(): boolean { + return this._source.options.showLabels; + } + + tickVisible(): boolean { + return this._source.options.showLabels; + } + + textColor() { + return this._source.options.labelTextColor; + } + backColor() { + return this._source.options.labelColor; + } + movePoint(p: Point) { + this._p = p; + this.update(); + } +} + +export class CustomPrimitivesTimeAxisView extends CustomPrimitivesAxisView { + update() { + const timeScale = this._source.chart.timeScale(); + this._pos = timeScale.timeToCoordinate(this._p.time); + } + text() { + return this._source.options.timeLabelFormatter(this._p.time); + } +} + +export class CustomPrimitivesPriceAxisView extends CustomPrimitivesAxisView { + update() { + const series = this._source.series; + this._pos = series.priceToCoordinate(this._p.price); + } + text() { + return this._source.options.priceLabelFormatter(this._p.price); + } +} diff --git a/lwc-plugin-custom-primitives/src/custom-primitives.ts b/lwc-plugin-custom-primitives/src/custom-primitives.ts new file mode 100644 index 0000000..1dc770b --- /dev/null +++ b/lwc-plugin-custom-primitives/src/custom-primitives.ts @@ -0,0 +1,139 @@ +import { AutoscaleInfo, Logical, Time, DataChangedScope } from 'lightweight-charts'; +import { + CustomPrimitivesPriceAxisPaneView, + CustomPrimitivesTimeAxisPaneView, +} from './axis-pane-view'; +import { CustomPrimitivesPriceAxisView, CustomPrimitivesTimeAxisView } from './axis-view'; +import { Point, CustomPrimitivesDataSource } from './data-source'; +import { CustomPrimitivesOptions, defaultOptions } from './options'; +import { CustomPrimitivesPaneView } from './pane-view'; +import { PluginBase } from './plugin-base'; + +export class CustomPrimitives + extends PluginBase + implements CustomPrimitivesDataSource +{ + _options: CustomPrimitivesOptions; + _p1: Point; + _p2: Point; + _paneViews: CustomPrimitivesPaneView[]; + _timeAxisViews: CustomPrimitivesTimeAxisView[]; + _priceAxisViews: CustomPrimitivesPriceAxisView[]; + _priceAxisPaneViews: CustomPrimitivesPriceAxisPaneView[]; + _timeAxisPaneViews: CustomPrimitivesTimeAxisPaneView[]; + + constructor( + p1: Point, + p2: Point, + options: Partial = {} + ) { + super(); + this._p1 = p1; + this._p2 = p2; + this._options = { + ...defaultOptions, + ...options, + }; + this._paneViews = [new CustomPrimitivesPaneView(this)]; + this._timeAxisViews = [ + new CustomPrimitivesTimeAxisView(this, p1), + new CustomPrimitivesTimeAxisView(this, p2), + ]; + this._priceAxisViews = [ + new CustomPrimitivesPriceAxisView(this, p1), + new CustomPrimitivesPriceAxisView(this, p2), + ]; + this._priceAxisPaneViews = [new CustomPrimitivesPriceAxisPaneView(this, true)]; + this._timeAxisPaneViews = [new CustomPrimitivesTimeAxisPaneView(this, false)]; + } + + updateAllViews() { + //* Use this method to update any data required by the + //* views to draw. + this._paneViews.forEach(pw => pw.update()); + this._timeAxisViews.forEach(pw => pw.update()); + this._priceAxisViews.forEach(pw => pw.update()); + this._priceAxisPaneViews.forEach(pw => pw.update()); + this._timeAxisPaneViews.forEach(pw => pw.update()); + } + + priceAxisViews() { + //* Labels rendered on the price scale + return this._priceAxisViews; + } + + timeAxisViews() { + //* labels rendered on the time scale + return this._timeAxisViews; + } + + paneViews() { + //* rendering on the main chart pane + return this._paneViews; + } + + priceAxisPaneViews() { + //* rendering on the price scale + return this._priceAxisPaneViews; + } + + timeAxisPaneViews() { + //* rendering on the time scale + return this._timeAxisPaneViews; + } + + autoscaleInfo( + startTimePoint: Logical, + endTimePoint: Logical + ): AutoscaleInfo | null { + //* Use this method to provide autoscale information if your primitive + //* should have the ability to remain in view automatically. + if ( + this._timeCurrentlyVisible(this.p1.time, startTimePoint, endTimePoint) || + this._timeCurrentlyVisible(this.p2.time, startTimePoint, endTimePoint) + ) { + return { + priceRange: { + minValue: Math.min(this.p1.price, this.p2.price), + maxValue: Math.max(this.p1.price, this.p2.price), + }, + }; + } + return null; + } + + dataUpdated(scope: DataChangedScope): void { + //* This method will be called by PluginBase when the data on the + //* series has changed. + } + + _timeCurrentlyVisible( + time: Time, + startTimePoint: Logical, + endTimePoint: Logical + ): boolean { + const ts = this.chart.timeScale(); + const coordinate = ts.timeToCoordinate(time); + if (coordinate === null) return false; + const logical = ts.coordinateToLogical(coordinate); + if (logical === null) return false; + return logical <= endTimePoint && logical >= startTimePoint; + } + + public get options(): CustomPrimitivesOptions { + return this._options; + } + + applyOptions(options: Partial) { + this._options = { ...this._options, ...options }; + this.requestUpdate(); + } + + public get p1(): Point { + return this._p1; + } + + public get p2(): Point { + return this._p2; + } +} diff --git a/lwc-plugin-custom-primitives/src/data-source.ts b/lwc-plugin-custom-primitives/src/data-source.ts new file mode 100644 index 0000000..6c14142 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/data-source.ts @@ -0,0 +1,20 @@ +import { + IChartApi, + ISeriesApi, + SeriesOptionsMap, + Time, +} from 'lightweight-charts'; +import { CustomPrimitivesOptions } from './options'; + +export interface Point { + time: Time; + price: number; +} + +export interface CustomPrimitivesDataSource { + chart: IChartApi; + series: ISeriesApi; + options: CustomPrimitivesOptions; + p1: Point; + p2: Point; +} diff --git a/lwc-plugin-custom-primitives/src/example/example.ts b/lwc-plugin-custom-primitives/src/example/example.ts new file mode 100644 index 0000000..a884dbe --- /dev/null +++ b/lwc-plugin-custom-primitives/src/example/example.ts @@ -0,0 +1,23 @@ +import { createChart } from 'lightweight-charts'; +import { generateLineData } from '../sample-data'; +import { CustomPrimitives } from '../custom-primitives'; + +const chart = ((window as unknown as any).chart = createChart('chart', { + autoSize: true, +})); + +const lineSeries = chart.addLineSeries({ + color: '#000000', +}); +const data = generateLineData(); +lineSeries.setData(data); + +const time1 = data[data.length - 50].time; +const time2 = data[data.length - 10].time; + +const primitive = new CustomPrimitives( + { price: 100, time: time1 }, + { price: 500, time: time2 } +); + +lineSeries.attachPrimitive(primitive); diff --git a/lwc-plugin-custom-primitives/src/example/index.html b/lwc-plugin-custom-primitives/src/example/index.html new file mode 100644 index 0000000..ed1334b --- /dev/null +++ b/lwc-plugin-custom-primitives/src/example/index.html @@ -0,0 +1,26 @@ + + + + + + Template Drawing Primitive Plugin Example + + + +
+ + + diff --git a/lwc-plugin-custom-primitives/src/helpers/assertions.ts b/lwc-plugin-custom-primitives/src/helpers/assertions.ts new file mode 100644 index 0000000..e68742f --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/assertions.ts @@ -0,0 +1,33 @@ +/** + * Ensures that value is defined. + * Throws if the value is undefined, returns the original value otherwise. + * + * @param value - The value, or undefined. + * @returns The passed value, if it is not undefined + */ +export function ensureDefined(value: undefined): never; +export function ensureDefined(value: T | undefined): T; +export function ensureDefined(value: T | undefined): T { + if (value === undefined) { + throw new Error('Value is undefined'); + } + + return value; +} + +/** + * Ensures that value is not null. + * Throws if the value is null, returns the original value otherwise. + * + * @param value - The value, or null. + * @returns The passed value, if it is not null + */ +export function ensureNotNull(value: null): never; +export function ensureNotNull(value: T | null): T; +export function ensureNotNull(value: T | null): T { + if (value === null) { + throw new Error('Value is null'); + } + + return value; +} diff --git a/lwc-plugin-custom-primitives/src/helpers/dimensions/common.ts b/lwc-plugin-custom-primitives/src/helpers/dimensions/common.ts new file mode 100644 index 0000000..394c6bb --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/dimensions/common.ts @@ -0,0 +1,6 @@ +export interface BitmapPositionLength { + /** coordinate for use with a bitmap rendering scope */ + position: number; + /** length for use with a bitmap rendering scope */ + length: number; +} diff --git a/lwc-plugin-custom-primitives/src/helpers/dimensions/crosshair-width.ts b/lwc-plugin-custom-primitives/src/helpers/dimensions/crosshair-width.ts new file mode 100644 index 0000000..9d14991 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/dimensions/crosshair-width.ts @@ -0,0 +1,23 @@ +/** + * Default grid / crosshair line width in Bitmap sizing + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns default grid / crosshair line width in Bitmap sizing + */ +export function gridAndCrosshairBitmapWidth( + horizontalPixelRatio: number +): number { + return Math.max(1, Math.floor(horizontalPixelRatio)); +} + +/** + * Default grid / crosshair line width in Media sizing + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns default grid / crosshair line width in Media sizing + */ +export function gridAndCrosshairMediaWidth( + horizontalPixelRatio: number +): number { + return ( + gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio + ); +} diff --git a/lwc-plugin-custom-primitives/src/helpers/dimensions/full-width.ts b/lwc-plugin-custom-primitives/src/helpers/dimensions/full-width.ts new file mode 100644 index 0000000..6f4d40d --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/dimensions/full-width.ts @@ -0,0 +1,29 @@ +import { BitmapPositionLength } from './common'; + +/** + * Calculates the position and width which will completely full the space for the bar. + * Useful if you want to draw something that will not have any gaps between surrounding bars. + * @param xMedia - x coordinate of the bar defined in media sizing + * @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded) + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns position and width which will completely full the space for the bar + */ +export function fullBarWidth( + xMedia: number, + halfBarSpacingMedia: number, + horizontalPixelRatio: number +): BitmapPositionLength { + const fullWidthLeftMedia = xMedia - halfBarSpacingMedia; + const fullWidthRightMedia = xMedia + halfBarSpacingMedia; + const fullWidthLeftBitmap = Math.round( + fullWidthLeftMedia * horizontalPixelRatio + ); + const fullWidthRightBitmap = Math.round( + fullWidthRightMedia * horizontalPixelRatio + ); + const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap; + return { + position: fullWidthLeftBitmap, + length: fullWidthBitmap, + }; +} diff --git a/lwc-plugin-custom-primitives/src/helpers/dimensions/positions.ts b/lwc-plugin-custom-primitives/src/helpers/dimensions/positions.ts new file mode 100644 index 0000000..a021b07 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/dimensions/positions.ts @@ -0,0 +1,48 @@ +import { BitmapPositionLength } from './common'; + +function centreOffset(lineBitmapWidth: number): number { + return Math.floor(lineBitmapWidth * 0.5); +} + +/** + * Calculates the bitmap position for an item with a desired length (height or width), and centred according to + * an position coordinate defined in media sizing. + * @param positionMedia - position coordinate for the bar (in media coordinates) + * @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions + * @param desiredWidthMedia - desired width (in media coordinates) + * @returns Position of of the start point and length dimension. + */ +export function positionsLine( + positionMedia: number, + pixelRatio: number, + desiredWidthMedia: number = 1, + widthIsBitmap?: boolean +): BitmapPositionLength { + const scaledPosition = Math.round(pixelRatio * positionMedia); + const lineBitmapWidth = widthIsBitmap + ? desiredWidthMedia + : Math.round(desiredWidthMedia * pixelRatio); + const offset = centreOffset(lineBitmapWidth); + const position = scaledPosition - offset; + return { position, length: lineBitmapWidth }; +} + +/** + * Determines the bitmap position and length for a dimension of a shape to be drawn. + * @param position1Media - media coordinate for the first point + * @param position2Media - media coordinate for the second point + * @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal) + * @returns Position of of the start point and length dimension. + */ +export function positionsBox( + position1Media: number, + position2Media: number, + pixelRatio: number +): BitmapPositionLength { + const scaledPosition1 = Math.round(pixelRatio * position1Media); + const scaledPosition2 = Math.round(pixelRatio * position2Media); + return { + position: Math.min(scaledPosition1, scaledPosition2), + length: Math.abs(scaledPosition2 - scaledPosition1) + 1, + }; +} diff --git a/lwc-plugin-custom-primitives/src/helpers/time.ts b/lwc-plugin-custom-primitives/src/helpers/time.ts new file mode 100644 index 0000000..ed65475 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/helpers/time.ts @@ -0,0 +1,34 @@ +import { Time, isUTCTimestamp, isBusinessDay } from 'lightweight-charts'; + +export function convertTime(t: Time): number { + if (isUTCTimestamp(t)) return t * 1000; + if (isBusinessDay(t)) return new Date(t.year, t.month, t.day).valueOf(); + const [year, month, day] = t.split('-').map(parseInt); + return new Date(year, month, day).valueOf(); +} + +export function displayTime(time: Time): string { + if (typeof time == 'string') return time; + const date = isBusinessDay(time) + ? new Date(time.year, time.month, time.day) + : new Date(time * 1000); + return date.toLocaleDateString(); +} + +export function formattedDateAndTime(timestamp: number | undefined): [string, string] { + if (!timestamp) return ['', '']; + const dateObj = new Date(timestamp); + + // Format date string + const year = dateObj.getFullYear(); + const month = dateObj.toLocaleString('default', { month: 'short' }); + const date = dateObj.getDate().toString().padStart(2, '0'); + const formattedDate = `${date} ${month} ${year}`; + + // Format time string + const hours = dateObj.getHours().toString().padStart(2, '0'); + const minutes = dateObj.getMinutes().toString().padStart(2, '0'); + const formattedTime = `${hours}:${minutes}`; + + return [formattedDate, formattedTime]; +} diff --git a/lwc-plugin-custom-primitives/src/options.ts b/lwc-plugin-custom-primitives/src/options.ts new file mode 100644 index 0000000..04fa40c --- /dev/null +++ b/lwc-plugin-custom-primitives/src/options.ts @@ -0,0 +1,27 @@ +import { Time, isBusinessDay } from 'lightweight-charts'; + +export interface CustomPrimitivesOptions { + //* Define the options for the primitive. + fillColor: string; + labelColor: string; + labelTextColor: string; + showLabels: boolean; + priceLabelFormatter: (price: number) => string; + timeLabelFormatter: (time: Time) => string; +} + +export const defaultOptions: CustomPrimitivesOptions = { + //* Define the default values for all the primitive options. + fillColor: 'rgba(200, 50, 100, 0.75)', + labelColor: 'rgba(200, 50, 100, 1)', + labelTextColor: 'white', + showLabels: true, + priceLabelFormatter: (price: number) => price.toFixed(2), + timeLabelFormatter: (time: Time) => { + if (typeof time == 'string') return time; + const date = isBusinessDay(time) + ? new Date(time.year, time.month, time.day) + : new Date(time * 1000); + return date.toLocaleDateString(); + }, +} as const; diff --git a/lwc-plugin-custom-primitives/src/pane-renderer.ts b/lwc-plugin-custom-primitives/src/pane-renderer.ts new file mode 100644 index 0000000..a69c6cd --- /dev/null +++ b/lwc-plugin-custom-primitives/src/pane-renderer.ts @@ -0,0 +1,46 @@ +import { CanvasRenderingTarget2D } from 'fancy-canvas'; +import { ISeriesPrimitivePaneRenderer } from 'lightweight-charts'; +import { ViewPoint } from './pane-view'; +import { positionsBox } from './helpers/dimensions/positions'; + +export class CustomPrimitivesPaneRenderer implements ISeriesPrimitivePaneRenderer { + _p1: ViewPoint; + _p2: ViewPoint; + _fillColor: string; + + constructor(p1: ViewPoint, p2: ViewPoint, fillColor: string) { + this._p1 = p1; + this._p2 = p2; + this._fillColor = fillColor; + } + + draw(target: CanvasRenderingTarget2D) { + target.useBitmapCoordinateSpace(scope => { + if ( + this._p1.x === null || + this._p1.y === null || + this._p2.x === null || + this._p2.y === null + ) + return; + const ctx = scope.context; + const horizontalPositions = positionsBox( + this._p1.x, + this._p2.x, + scope.horizontalPixelRatio + ); + const verticalPositions = positionsBox( + this._p1.y, + this._p2.y, + scope.verticalPixelRatio + ); + ctx.fillStyle = this._fillColor; + ctx.fillRect( + horizontalPositions.position, + verticalPositions.position, + horizontalPositions.length, + verticalPositions.length + ); + }); + } +} diff --git a/lwc-plugin-custom-primitives/src/pane-view.ts b/lwc-plugin-custom-primitives/src/pane-view.ts new file mode 100644 index 0000000..810538c --- /dev/null +++ b/lwc-plugin-custom-primitives/src/pane-view.ts @@ -0,0 +1,37 @@ +import { Coordinate, ISeriesPrimitivePaneView } from 'lightweight-charts'; +import { CustomPrimitivesPaneRenderer } from './pane-renderer'; +import { CustomPrimitivesDataSource } from './data-source'; + +export interface ViewPoint { + x: Coordinate | null; + y: Coordinate | null; +} + +export class CustomPrimitivesPaneView implements ISeriesPrimitivePaneView { + _source: CustomPrimitivesDataSource; + _p1: ViewPoint = { x: null, y: null }; + _p2: ViewPoint = { x: null, y: null }; + + constructor(source: CustomPrimitivesDataSource) { + this._source = source; + } + + update() { + const series = this._source.series; + const y1 = series.priceToCoordinate(this._source.p1.price); + const y2 = series.priceToCoordinate(this._source.p2.price); + const timeScale = this._source.chart.timeScale(); + const x1 = timeScale.timeToCoordinate(this._source.p1.time); + const x2 = timeScale.timeToCoordinate(this._source.p2.time); + this._p1 = { x: x1, y: y1 }; + this._p2 = { x: x2, y: y2 }; + } + + renderer() { + return new CustomPrimitivesPaneRenderer( + this._p1, + this._p2, + this._source.options.fillColor + ); + } +} diff --git a/lwc-plugin-custom-primitives/src/plugin-base.ts b/lwc-plugin-custom-primitives/src/plugin-base.ts new file mode 100644 index 0000000..def9684 --- /dev/null +++ b/lwc-plugin-custom-primitives/src/plugin-base.ts @@ -0,0 +1,56 @@ +import { + DataChangedScope, + IChartApi, + ISeriesApi, + ISeriesPrimitive, + SeriesAttachedParameter, + SeriesOptionsMap, + Time, +} from 'lightweight-charts'; +import { ensureDefined } from './helpers/assertions'; + +//* PluginBase is a useful base to build a plugin upon which +//* already handles creating getters for the chart and series, +//* and provides a requestUpdate method. +export abstract class PluginBase implements ISeriesPrimitive