mirror of
https://github.com/prodigist/ReactiveNotes.git
synced 2026-07-22 12:30:26 +00:00
Initial commit: ReactiveNotes plugin
This commit is contained in:
commit
0be88d7908
68 changed files with 11316 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -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
|
||||
6
.env
Normal file
6
.env
Normal file
|
|
@ -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
|
||||
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -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
|
||||
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
.gitignore
vendored
Normal file
BIN
.gitignore
vendored
Normal file
Binary file not shown.
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
94
README.md
Normal file
94
README.md
Normal file
|
|
@ -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
|
||||
66
esbuild.config.mjs
Normal file
66
esbuild.config.mjs
Normal file
|
|
@ -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();
|
||||
}
|
||||
24
lwc-plugin-custom-primitives/.gitignore
vendored
Normal file
24
lwc-plugin-custom-primitives/.gitignore
vendored
Normal file
|
|
@ -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?
|
||||
38
lwc-plugin-custom-primitives/README.md
Normal file
38
lwc-plugin-custom-primitives/README.md
Normal file
|
|
@ -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.
|
||||
116
lwc-plugin-custom-primitives/compile.mjs
Normal file
116
lwc-plugin-custom-primitives/compile.mjs
Normal file
|
|
@ -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)`);
|
||||
7
lwc-plugin-custom-primitives/index.html
Normal file
7
lwc-plugin-custom-primitives/index.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- redirect to example page -->
|
||||
<meta http-equiv="refresh" content="0; URL=src/example/" />
|
||||
</head>
|
||||
</html>
|
||||
17
lwc-plugin-custom-primitives/package.json
Normal file
17
lwc-plugin-custom-primitives/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
41
lwc-plugin-custom-primitives/src/axis-pane-renderer.ts
Normal file
41
lwc-plugin-custom-primitives/src/axis-pane-renderer.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
55
lwc-plugin-custom-primitives/src/axis-pane-view.ts
Normal file
55
lwc-plugin-custom-primitives/src/axis-pane-view.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
57
lwc-plugin-custom-primitives/src/axis-view.ts
Normal file
57
lwc-plugin-custom-primitives/src/axis-view.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
139
lwc-plugin-custom-primitives/src/custom-primitives.ts
Normal file
139
lwc-plugin-custom-primitives/src/custom-primitives.ts
Normal file
|
|
@ -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<CustomPrimitivesOptions> = {}
|
||||
) {
|
||||
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<CustomPrimitivesOptions>) {
|
||||
this._options = { ...this._options, ...options };
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public get p1(): Point {
|
||||
return this._p1;
|
||||
}
|
||||
|
||||
public get p2(): Point {
|
||||
return this._p2;
|
||||
}
|
||||
}
|
||||
20
lwc-plugin-custom-primitives/src/data-source.ts
Normal file
20
lwc-plugin-custom-primitives/src/data-source.ts
Normal file
|
|
@ -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<keyof SeriesOptionsMap>;
|
||||
options: CustomPrimitivesOptions;
|
||||
p1: Point;
|
||||
p2: Point;
|
||||
}
|
||||
23
lwc-plugin-custom-primitives/src/example/example.ts
Normal file
23
lwc-plugin-custom-primitives/src/example/example.ts
Normal file
|
|
@ -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);
|
||||
26
lwc-plugin-custom-primitives/src/example/index.html
Normal file
26
lwc-plugin-custom-primitives/src/example/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Template Drawing Primitive Plugin Example</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: rgba(248, 249, 253, 1);
|
||||
color: rgba(19, 23, 34, 1);
|
||||
}
|
||||
#chart {
|
||||
margin-inline: auto;
|
||||
max-width: 600px;
|
||||
height: 300px;
|
||||
background-color: rgba(240, 243, 250, 1);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
<script type="module" src="./example.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
lwc-plugin-custom-primitives/src/helpers/assertions.ts
Normal file
33
lwc-plugin-custom-primitives/src/helpers/assertions.ts
Normal file
|
|
@ -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<T>(value: T | undefined): T;
|
||||
export function ensureDefined<T>(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<T>(value: T | null): T;
|
||||
export function ensureNotNull<T>(value: T | null): T {
|
||||
if (value === null) {
|
||||
throw new Error('Value is null');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
34
lwc-plugin-custom-primitives/src/helpers/time.ts
Normal file
34
lwc-plugin-custom-primitives/src/helpers/time.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
27
lwc-plugin-custom-primitives/src/options.ts
Normal file
27
lwc-plugin-custom-primitives/src/options.ts
Normal file
|
|
@ -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;
|
||||
46
lwc-plugin-custom-primitives/src/pane-renderer.ts
Normal file
46
lwc-plugin-custom-primitives/src/pane-renderer.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
37
lwc-plugin-custom-primitives/src/pane-view.ts
Normal file
37
lwc-plugin-custom-primitives/src/pane-view.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lwc-plugin-custom-primitives/src/plugin-base.ts
Normal file
56
lwc-plugin-custom-primitives/src/plugin-base.ts
Normal file
|
|
@ -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<Time> {
|
||||
private _chart: IChartApi | undefined = undefined;
|
||||
private _series: ISeriesApi<keyof SeriesOptionsMap> | undefined = undefined;
|
||||
|
||||
protected dataUpdated?(scope: DataChangedScope): void;
|
||||
protected requestUpdate(): void {
|
||||
if (this._requestUpdate) this._requestUpdate();
|
||||
}
|
||||
private _requestUpdate?: () => void;
|
||||
|
||||
public attached({
|
||||
chart,
|
||||
series,
|
||||
requestUpdate,
|
||||
}: SeriesAttachedParameter<Time>) {
|
||||
this._chart = chart;
|
||||
this._series = series;
|
||||
this._series.subscribeDataChanged(this._fireDataUpdated);
|
||||
this._requestUpdate = requestUpdate;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public detached() {
|
||||
this._chart = undefined;
|
||||
this._series = undefined;
|
||||
this._requestUpdate = undefined;
|
||||
}
|
||||
|
||||
public get chart(): IChartApi {
|
||||
return ensureDefined(this._chart);
|
||||
}
|
||||
|
||||
public get series(): ISeriesApi<keyof SeriesOptionsMap> {
|
||||
return ensureDefined(this._series);
|
||||
}
|
||||
|
||||
private _fireDataUpdated(scope: DataChangedScope) {
|
||||
if (this.dataUpdated) {
|
||||
this.dataUpdated(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
lwc-plugin-custom-primitives/src/sample-data.ts
Normal file
35
lwc-plugin-custom-primitives/src/sample-data.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Time } from 'lightweight-charts';
|
||||
|
||||
type LineData = {
|
||||
time: Time;
|
||||
value: number;
|
||||
};
|
||||
|
||||
let randomFactor = 25 + Math.random() * 25;
|
||||
const samplePoint = (i: number) =>
|
||||
i *
|
||||
(0.5 +
|
||||
Math.sin(i / 10) * 0.2 +
|
||||
Math.sin(i / 20) * 0.4 +
|
||||
Math.sin(i / randomFactor) * 0.8 +
|
||||
Math.sin(i / 500) * 0.5) +
|
||||
200;
|
||||
|
||||
export function generateLineData(numberOfPoints: number = 500): LineData[] {
|
||||
randomFactor = 25 + Math.random() * 25;
|
||||
const res = [];
|
||||
const date = new Date(Date.UTC(2023, 0, 1, 12, 0, 0, 0));
|
||||
for (let i = 0; i < numberOfPoints; ++i) {
|
||||
const time = (date.getTime() / 1000) as Time;
|
||||
const value = samplePoint(i);
|
||||
res.push({
|
||||
time,
|
||||
value,
|
||||
});
|
||||
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
1
lwc-plugin-custom-primitives/src/vite-env.d.ts
vendored
Normal file
1
lwc-plugin-custom-primitives/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
13
lwc-plugin-custom-primitives/src/vite.config.js
Normal file
13
lwc-plugin-custom-primitives/src/vite.config.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
const input = {
|
||||
main: './src/example/index.html',
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input,
|
||||
},
|
||||
},
|
||||
});
|
||||
21
lwc-plugin-custom-primitives/tsconfig.json
Normal file
21
lwc-plugin-custom-primitives/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationDir": "typings",
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
407
main.tsx
Normal file
407
main.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { Plugin, MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownRenderer, TFile, App, stringifyYaml } from 'obsidian';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { transform } from '@babel/standalone';
|
||||
import * as React from 'react';
|
||||
import postcss from 'postcss';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import { StorageManager } from 'src/core/storage';
|
||||
import { useStorage } from 'src/hooks/useStorage';
|
||||
import { createChart } from 'lightweight-charts';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from 'src/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'src/components/ui/tabs';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { useMarketData } from 'src/core/useMarketData';
|
||||
import { createMarketDataHook } from 'src/core/useMarketData';
|
||||
import { MarketDataService } from 'src/services/marketDataService';
|
||||
import { Switch } from 'src/components/ui/switch';
|
||||
import { OrderBlockAnalysisService } from 'src/services/OrderBlockAnalysis';
|
||||
import { Upload, Activity,AlertCircle, TrendingUp, TrendingDown, } from 'lucide-react';
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';
|
||||
// Add all React components you want to make available
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ComposedChart,
|
||||
BarChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Bar,
|
||||
Line,
|
||||
Area,
|
||||
ReferenceLine,
|
||||
LineChart,
|
||||
} from 'recharts';
|
||||
import { MarketDataStorage } from 'src/services/marketDataStorage';
|
||||
|
||||
class ReactComponentChild extends MarkdownRenderChild {
|
||||
private root: ReturnType<typeof createRoot>;
|
||||
private storage: StorageManager;
|
||||
private static styleSheet: HTMLStyleElement | null = null;
|
||||
private static componentStyles = new Set<string>();
|
||||
private noteFile: TFile;
|
||||
private ctx: MarkdownPostProcessorContext;
|
||||
private app: App;
|
||||
|
||||
constructor(containerEl: HTMLElement, plugin: Plugin, ctx: MarkdownPostProcessorContext) {
|
||||
super(containerEl);
|
||||
this.root = createRoot(containerEl);
|
||||
this.storage = new StorageManager(plugin);
|
||||
this.ctx = ctx;
|
||||
this.app = plugin.app; // Get app from plugin
|
||||
// Get the source file from context
|
||||
if (ctx.sourcePath) {
|
||||
this.noteFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath) as TFile;
|
||||
}
|
||||
// Add these classes to the container
|
||||
containerEl.classList.add('react-component-container');
|
||||
if (document.body.hasClass('theme-dark')) {
|
||||
containerEl.classList.add('theme-dark');
|
||||
}else {
|
||||
containerEl.classList.add('theme-light');
|
||||
}
|
||||
}
|
||||
|
||||
private async getFrontmatterData<T>(key: string, defaultValue: T): Promise<T> {
|
||||
if (!this.noteFile) return defaultValue;
|
||||
|
||||
const cache = this.app.metadataCache.getFileCache(this.noteFile);
|
||||
const frontmatter = cache?.frontmatter;
|
||||
return frontmatter?.react_data?.[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
private async updateFrontmatterData<T>(key: string, value: T): Promise<void> {
|
||||
if (!this.noteFile) return;
|
||||
|
||||
const content = await this.app.vault.read(this.noteFile);
|
||||
const cache = this.app.metadataCache.getFileCache(this.noteFile);
|
||||
const frontmatter = cache?.frontmatter || {};
|
||||
|
||||
const newFrontmatter = {
|
||||
...frontmatter,
|
||||
react_data: {
|
||||
...(frontmatter.react_data || {}),
|
||||
[key]: value
|
||||
}
|
||||
};
|
||||
|
||||
const yamlRegex = /^---\n([\s\S]*?)\n---/;
|
||||
const yamlMatch = content.match(yamlRegex);
|
||||
|
||||
let newContent;
|
||||
if (yamlMatch) {
|
||||
newContent = content.replace(yamlRegex, `---\n${stringifyYaml(newFrontmatter)}---`);
|
||||
} else {
|
||||
newContent = `---\n${stringifyYaml(newFrontmatter)}---\n\n${content}`;
|
||||
}
|
||||
|
||||
await this.app.vault.modify(this.noteFile, newContent);
|
||||
}
|
||||
|
||||
// Add wrapper component for error boundary
|
||||
private RenderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={({ error }) => (
|
||||
<div className="react-component-error">
|
||||
<p>Error in component:</p>
|
||||
<pre className="error-message">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
private preprocessCode(code: string): string {
|
||||
// Remove imports more carefully
|
||||
code = code.replace(/import\s+.*?['"]\s*;?\s*$/gm, '');
|
||||
code = code.replace(/import\s*{[^}]*}\s*from\s*['"][^'"]*['"];?\s*$/gm, '');
|
||||
code = code.replace(/import\s*\([^)]*\);?\s*$/gm, '');
|
||||
|
||||
// Handle both JS/TS exports
|
||||
code = code.replace(/export\s+default\s+/, '');
|
||||
code = code.replace(/export\s+const\s+/, 'const ');
|
||||
code = code.replace(/export\s+function\s+/, 'function ');
|
||||
code = code.replace(/export\s+class\s+/, 'class ');
|
||||
|
||||
// Remove type annotations
|
||||
//code = code.replace(/:\s*[A-Za-z<>[\]]+/g, '');
|
||||
//code = code.replace(/<[A-Za-z,\s]+>/g, '');
|
||||
// Create a HOC wrapper for chart components
|
||||
const chartWrapper = `
|
||||
const withChartContainer = (WrappedComponent) => {
|
||||
return function ChartContainer(props) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setMounted(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ style: { width: '100%', minHeight: '400px' } },
|
||||
mounted ? React.createElement(WrappedComponent, props) : null
|
||||
);
|
||||
};
|
||||
};
|
||||
`;
|
||||
|
||||
// Find the component name
|
||||
const componentMatch = code.match(/(?:const|function|class)\s+(\w+)\s*=\s*(?:(?:\([^)]*\)|)\s*=>|function\s*\(|React\.memo\(|React\.forwardRef\(|class\s+extends\s+React\.Component)/);
|
||||
const componentName = componentMatch ? componentMatch[1] : 'EmptyComponent';
|
||||
console.log('Found component:', componentName); // Debug info
|
||||
if (!componentMatch) {
|
||||
throw new Error('No React component found');
|
||||
}
|
||||
|
||||
// Combine everything
|
||||
// added Component assignment
|
||||
code = `
|
||||
${chartWrapper}
|
||||
${code}
|
||||
const Component = (() => {
|
||||
// Add error handling for component existence
|
||||
if (typeof ${componentName} === 'undefined') {
|
||||
throw new Error(\`Component "${componentName}" was matched but is undefined. Code context: ${code.slice(0, 100)}...\`);
|
||||
}
|
||||
const isChartComponent = ${componentName}.toString().includes('ResponsiveContainer') ||
|
||||
${componentName}.toString().includes('svg');
|
||||
return isChartComponent ? withChartContainer(${componentName}) : ${componentName};
|
||||
})();
|
||||
`;
|
||||
|
||||
// Wrap code in async IIFE to allow for await
|
||||
return `
|
||||
(async () => {
|
||||
${code}
|
||||
return Component;
|
||||
})()
|
||||
`;
|
||||
}
|
||||
|
||||
async render(code: string) {
|
||||
console.time('component-render');
|
||||
try {
|
||||
//console.log('Original code:', code);
|
||||
console.time('preprocess');
|
||||
// Preprocess code
|
||||
const processedCode = this.preprocessCode(code);
|
||||
//console.log('Processed code:', processedCode);
|
||||
console.timeEnd('preprocess');
|
||||
|
||||
console.time('tailwind');
|
||||
// Process Tailwind
|
||||
//await this.processTailwind(processedCode);
|
||||
console.timeEnd('tailwind');
|
||||
|
||||
console.time('babel');
|
||||
const isTypeScript = code.includes(':') || code.includes('interface') || code.includes('<');
|
||||
|
||||
const presets: (string | [string, object])[] = isTypeScript ?
|
||||
['react', ['typescript', { isTSX: true, allExtensions: true }]] :
|
||||
['react'];
|
||||
// Transform with Babel
|
||||
const transformedCode = transform(processedCode, {
|
||||
presets,
|
||||
sourceType: 'module',
|
||||
filename: isTypeScript ? 'dynamic-component.tsx' : 'dynamic-component.jsx',
|
||||
configFile: false,
|
||||
babelrc: false,
|
||||
}).code;
|
||||
console.timeEnd('babel');
|
||||
//console.log('Transformed code:', transformedCode);
|
||||
|
||||
// Create bound storage hook
|
||||
// Create bound storage hook with frontmatter support
|
||||
const boundUseStorage = <T,>(key: string, defaultValue: T) => {
|
||||
const [value, setValue] = React.useState<T>(defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
this.getFrontmatterData(key, defaultValue).then(setValue);
|
||||
}, [key]);
|
||||
|
||||
const updateValue = React.useCallback(
|
||||
async (newValue: T | ((prev: T) => T)) => {
|
||||
const actualNewValue = newValue instanceof Function ?
|
||||
newValue(value) : newValue;
|
||||
setValue(actualNewValue);
|
||||
await this.updateFrontmatterData(key, actualNewValue);
|
||||
},
|
||||
[key, value]
|
||||
);
|
||||
|
||||
return [value, updateValue] as const;
|
||||
};
|
||||
// Create scoped market data hook
|
||||
const useMarketData = createMarketDataHook(this.storage, this.noteFile);
|
||||
// Create scope with all required dependencies
|
||||
const scope = {
|
||||
React,
|
||||
useState: React.useState,
|
||||
useEffect: React.useEffect,
|
||||
useRef: React.useRef,
|
||||
useMemo: React.useMemo,
|
||||
useStorage: boundUseStorage,
|
||||
useMarketData,
|
||||
useCallback:React.useCallback,
|
||||
MarketDataStorage,
|
||||
OrderBlockAnalysisService,
|
||||
MarketDataService,
|
||||
// Chart library
|
||||
createChart,
|
||||
// UI Components
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Switch,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ComposedChart, // Add these chart components
|
||||
BarChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Bar,
|
||||
Upload,
|
||||
Area,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
LineChart,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
TrendingUp, TrendingDown,
|
||||
forceSimulation, forceLink, forceManyBody, forceCenter,
|
||||
getTheme: () => document.body.hasClass('theme-dark') ? 'dark' : 'light',
|
||||
// Add note context
|
||||
noteContext: {
|
||||
path: this.noteFile?.path,
|
||||
basename: this.noteFile?.basename,
|
||||
frontmatter: this.app.metadataCache.getFileCache(this.noteFile)?.frontmatter
|
||||
},
|
||||
// Add useful chart utilities
|
||||
getChartTheme: () => document.body.hasClass('theme-dark') ? 'dark' : 'light',
|
||||
getChartDefaults: () => ({
|
||||
margin: { top: 10, right: 30, left: 0, bottom: 0 },
|
||||
style: {
|
||||
backgroundColor: document.body.hasClass('theme-dark') ? '#1a1b1e' : '#ffffff',
|
||||
color: document.body.hasClass('theme-dark') ? '#ffffff' : '#000000'
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
// Execute the code and get the component
|
||||
const Component = await new Function(
|
||||
...Object.keys(scope),
|
||||
`return ${transformedCode}`
|
||||
)(...Object.values(scope));
|
||||
|
||||
// Wrap the rendered component in ErrorBoundary
|
||||
this.root.render(
|
||||
<this.RenderWrapper>
|
||||
<Component />
|
||||
</this.RenderWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Rendering error:', error);
|
||||
this.renderError(error instanceof Error ? error : new Error('Unknown error'));
|
||||
}
|
||||
console.timeEnd('component-render');
|
||||
}
|
||||
|
||||
onunload() {
|
||||
this.root.unmount();
|
||||
}
|
||||
|
||||
renderError(error: Error) {
|
||||
this.root.render(
|
||||
React.createElement('div', {
|
||||
style: {
|
||||
color: 'red',
|
||||
padding: '1rem',
|
||||
border: '1px solid red',
|
||||
borderRadius: '4px',
|
||||
margin: '1rem 0'
|
||||
}
|
||||
}, `Error: ${error.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
getStorage() {
|
||||
return this.storage;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ReactTestPlugin extends Plugin {
|
||||
async onload() {
|
||||
// Listen for theme changes
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('css-change', this.updateTheme)
|
||||
);
|
||||
// Register markdown processor
|
||||
this.registerMarkdownCodeBlockProcessor(
|
||||
'react',
|
||||
(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
||||
const child = new ReactComponentChild(el, this, ctx);
|
||||
ctx.addChild(child);
|
||||
child.render(source);
|
||||
}
|
||||
);
|
||||
// Register a global Markdown postprocessor for HTML
|
||||
this.registerMarkdownPostProcessor((element, context) => {
|
||||
this.parseMarkdownInHtml(element);
|
||||
});
|
||||
// Initial theme setup
|
||||
this.updateTheme();
|
||||
}
|
||||
private updateTheme = () => {
|
||||
// Update theme class on all react component containers
|
||||
document.querySelectorAll('.react-component-container').forEach(el => {
|
||||
if (document.body.hasClass('theme-dark')) {
|
||||
el.classList.add('theme-dark');
|
||||
el.classList.remove('theme-light');
|
||||
} else {
|
||||
el.classList.add('theme-light');
|
||||
el.classList.remove('theme-dark');
|
||||
}
|
||||
});
|
||||
};
|
||||
private async parseMarkdownInHtml(container: HTMLElement) {
|
||||
// Query all divs or specific HTML blocks you want to process
|
||||
const htmlBlocks = Array.from(container.querySelectorAll('div')) as HTMLDivElement[];
|
||||
|
||||
// Iterate over each div block
|
||||
for (const block of htmlBlocks) {
|
||||
// Check if it already contains rendered Markdown (to avoid double processing)
|
||||
if (block.querySelector('.markdown-rendered')) continue;
|
||||
|
||||
// Render Markdown for the inner content of each block
|
||||
await MarkdownRenderer.renderMarkdown(
|
||||
block.innerHTML, // Raw HTML content
|
||||
block, // Target container for rendered Markdown
|
||||
'', // Path (optional, current file path if needed)
|
||||
this // Plugin context
|
||||
);
|
||||
|
||||
// After rendering, mark the block to avoid double processing
|
||||
block.classList.add('markdown-rendered');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
11
manifest.json
Normal file
11
manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "reactive-notes",
|
||||
"name": "Reactive Notes",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Enhances the Obsidian API with react.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"fundingUrl": "https://obsidian.md/pricing",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
6204
package-lock.json
generated
Normal file
6204
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
58
package.json
Normal file
58
package.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.25.9",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/standalone": "^7.26.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^4.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.10.6",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@lezer/common": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"lightweight-charts": "^4.2.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "^1.6.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph": "^1.46.0",
|
||||
"recharts": "^2.13.3",
|
||||
"shadcn": "^1.0.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"yaml": "^2.6.1"
|
||||
}
|
||||
}
|
||||
4
react-shim.js
vendored
Normal file
4
react-shim.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// react-shim.js
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
export { React, ReactDOM };
|
||||
133
src/components/ComponentRenderer.tsx
Normal file
133
src/components/ComponentRenderer.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// src/components/ComponentRenderer.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { transform } from '@babel/standalone';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { App } from 'obsidian';
|
||||
import { useStorage } from '../hooks/useStorage';
|
||||
import { ComponentRegistry } from '../core/registry';
|
||||
import { StorageManager } from '../core/storage';
|
||||
|
||||
interface ComponentRendererProps {
|
||||
code: string;
|
||||
scopeId: string;
|
||||
inline?: boolean;
|
||||
storage: StorageManager; // Add storage prop
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TransformError {
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
const ErrorDisplay: React.FC<{ error: TransformError }> = ({ error }) => {
|
||||
return (
|
||||
<div className="react-component-error">
|
||||
<p>Error in component:</p>
|
||||
<pre className="error-message">
|
||||
{error.message}
|
||||
{error.line && error.column &&
|
||||
`\nAt line ${error.line}, column ${error.column}`
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
|
||||
code,
|
||||
scopeId,
|
||||
storage, // Get storage from props
|
||||
inline = false
|
||||
}) => {
|
||||
const [Component, setComponent] = useState<React.ComponentType | null>(null);
|
||||
const [error, setError] = useState<TransformError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const createComponent = async () => {
|
||||
try {
|
||||
const transformedCode = transform(code, {
|
||||
presets: ['env','react'],
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-jsx', // Add JSX syntax plugin explicitly
|
||||
'@babel/plugin-transform-react-jsx', // Transforms JSX into JS
|
||||
'@babel/plugin-proposal-class-properties', // Handles class properties
|
||||
'@babel/plugin-proposal-object-rest-spread', // Handles object spread syntax
|
||||
],
|
||||
filename: `component-${scopeId}`,
|
||||
sourceType: 'module',
|
||||
configFile: false,
|
||||
babelrc: false
|
||||
}).code;
|
||||
console.log('Transformed Code:', transformedCode);
|
||||
// Create storage hook bound to this storage instance
|
||||
const boundUseStorage = <T,>(key: string, defaultValue: T) =>
|
||||
useStorage(key, defaultValue, storage);
|
||||
|
||||
// Enhanced scope with registry and storage
|
||||
const scope = {
|
||||
React,
|
||||
useState: React.useState,
|
||||
useEffect: React.useEffect,
|
||||
useRef: React.useRef,
|
||||
useStorage: boundUseStorage, // Add bound storage hook
|
||||
...ComponentRegistry,
|
||||
};
|
||||
|
||||
const componentFn = new Function(
|
||||
...Object.keys(scope),
|
||||
`try {
|
||||
${transformedCode}
|
||||
return Component;
|
||||
} catch (err) {
|
||||
console.error('Error in component execution:', err);
|
||||
throw err;
|
||||
}`
|
||||
);
|
||||
|
||||
const ComponentType = componentFn(...Object.values(scope));
|
||||
setComponent(() => ComponentType);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Component transformation failed:', err);
|
||||
setError({
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
line: (err as any).loc?.line,
|
||||
column: (err as any).loc?.column
|
||||
});
|
||||
setComponent(null);
|
||||
}
|
||||
};
|
||||
|
||||
createComponent();
|
||||
}, [code, scopeId, storage]); // Add storage to dependencies
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> =
|
||||
inline ? ({ children }) => (
|
||||
<span className="inline-component">{children}</span>
|
||||
) : ({ children }) => (
|
||||
<div className="block-component">{children}</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} />;
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div
|
||||
className="component-sandbox"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Component />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
135
src/components/ErrorBoundary.tsx
Normal file
135
src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// src/components/ErrorBoundary.tsx
|
||||
import React from 'react';
|
||||
import { RefreshCw } from 'lucide-react'; // Assuming you're using lucide-react for icons
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ComponentType<{ error: Error }>;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// Log error to console for debugging
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
const FallbackComponent = this.props.fallback;
|
||||
return <FallbackComponent error={this.state.error!} />;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="react-component-error p-4 rounded-md border border-red-500 bg-red-50 dark:bg-red-900/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-red-700 dark:text-red-400 font-medium">
|
||||
Component Error
|
||||
</h4>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm rounded
|
||||
bg-red-100 dark:bg-red-800/30
|
||||
hover:bg-red-200 dark:hover:bg-red-800/50
|
||||
text-red-700 dark:text-red-400
|
||||
transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-red-600 dark:text-red-300">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</div>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-red-500 dark:text-red-400 cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 text-xs overflow-auto bg-red-100/50 dark:bg-red-950/50 rounded">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Higher-order component for easier usage
|
||||
export function withErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: React.ComponentType<{ error: Error }>
|
||||
): React.FC<P> {
|
||||
return function WrappedComponent(props: P) {
|
||||
return (
|
||||
<ErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Example usage of custom fallback:
|
||||
/*
|
||||
const CustomFallback: React.FC<{ error: Error }> = ({ error }) => (
|
||||
<div className="custom-error">
|
||||
<h4>Something went wrong</h4>
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use with custom fallback
|
||||
<ErrorBoundary fallback={CustomFallback}>
|
||||
<YourComponent />
|
||||
</ErrorBoundary>
|
||||
|
||||
// Or use HOC
|
||||
const SafeComponent = withErrorBoundary(YourComponent, CustomFallback);
|
||||
*/
|
||||
36
src/components/OrderblockVisualiser.ts
Normal file
36
src/components/OrderblockVisualiser.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// src/utils/orderBlockVisualization.ts
|
||||
import { IChartApi, ISeriesApi } from 'lightweight-charts';
|
||||
import { Rectangle } from '../plugins/RectanglePlugin';
|
||||
import { OrderBlock } from '../services/OrderBlockAnalysis';
|
||||
|
||||
export function visualizeOrderBlocks(
|
||||
chart: IChartApi,
|
||||
mainSeries: ISeriesApi<"Candlestick">,
|
||||
orderBlocks: OrderBlock[]
|
||||
) {
|
||||
orderBlocks.forEach(block => {
|
||||
const rectangle = new Rectangle(
|
||||
{
|
||||
time: block.startTime / 1000, // Convert to seconds for lightweight-charts
|
||||
price: block.highPrice
|
||||
},
|
||||
{
|
||||
time: block.endTime / 1000,
|
||||
price: block.lowPrice
|
||||
},
|
||||
{
|
||||
fillColor: block.type === 'bullish'
|
||||
? 'rgba(34, 197, 94, 0.2)' // Green
|
||||
: 'rgba(239, 68, 68, 0.2)', // Red
|
||||
borderColor: block.type === 'bullish'
|
||||
? 'rgba(34, 197, 94, 1)'
|
||||
: 'rgba(239, 68, 68, 1)',
|
||||
borderWidth: block.validationMetrics.impulseStrength > 1.5 ? 2 : 1,
|
||||
opacity: block.validationMetrics.gapQuality,
|
||||
extend: 'right' // Extend to the right of the chart
|
||||
}
|
||||
);
|
||||
|
||||
mainSeries.attachPrimitive(rectangle);
|
||||
});
|
||||
}
|
||||
92
src/components/RectPlugin.ts
Normal file
92
src/components/RectPlugin.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// src/component/RectPlugin.ts
|
||||
interface Point {
|
||||
time: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface RectangleOptions {
|
||||
fillColor?: string;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
opacity?: number;
|
||||
extend?: 'none' | 'right';
|
||||
}
|
||||
|
||||
export class Rectangle {
|
||||
private _point1: Point;
|
||||
private _point2: Point;
|
||||
private _options: Required<RectangleOptions>;
|
||||
|
||||
private static readonly defaultOptions: Required<RectangleOptions> = {
|
||||
fillColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 1)',
|
||||
borderWidth: 1,
|
||||
opacity: 0.2,
|
||||
extend: 'none'
|
||||
};
|
||||
|
||||
constructor(point1: Point, point2: Point, options: RectangleOptions = {}) {
|
||||
this._point1 = point1;
|
||||
this._point2 = point2;
|
||||
this._options = { ...Rectangle.defaultOptions, ...options };
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, pixelRatio: number, model: any): void {
|
||||
const points = this._calculatePoints(model);
|
||||
if (!points) return;
|
||||
|
||||
const { x1, y1, x2, y2 } = points;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Set line style
|
||||
ctx.lineWidth = this._options.borderWidth * pixelRatio;
|
||||
ctx.strokeStyle = this._options.borderColor;
|
||||
ctx.fillStyle = this._options.fillColor;
|
||||
|
||||
// Draw rectangle
|
||||
ctx.beginPath();
|
||||
ctx.rect(
|
||||
Math.round(x1 * pixelRatio),
|
||||
Math.round(y1 * pixelRatio),
|
||||
Math.round((x2 - x1) * pixelRatio),
|
||||
Math.round((y2 - y1) * pixelRatio)
|
||||
);
|
||||
|
||||
// Fill with semi-transparency
|
||||
ctx.globalAlpha = this._options.opacity;
|
||||
ctx.fill();
|
||||
|
||||
// Draw border
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
hitTest(x: number, y: number): boolean {
|
||||
// Implement hit testing if needed
|
||||
return false;
|
||||
}
|
||||
|
||||
private _calculatePoints(model: any) {
|
||||
const point1X = model.timeScale().timeToCoordinate(this._point1.time);
|
||||
const point2X = this._options.extend === 'right'
|
||||
? model.timeScale().getWidth()
|
||||
: model.timeScale().timeToCoordinate(this._point2.time);
|
||||
|
||||
const point1Y = model.priceScale('right').priceToCoordinate(this._point1.price);
|
||||
const point2Y = model.priceScale('right').priceToCoordinate(this._point2.price);
|
||||
|
||||
if (!point1X || !point2X || !point1Y || !point2Y) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x1: Math.min(point1X, point2X),
|
||||
y1: Math.min(point1Y, point2Y),
|
||||
x2: Math.max(point1X, point2X),
|
||||
y2: Math.max(point1Y, point2Y)
|
||||
};
|
||||
}
|
||||
}
|
||||
57
src/components/styled.tsx
Normal file
57
src/components/styled.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// components/styled.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface BoxProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
mt?: number;
|
||||
mb?: number;
|
||||
p?: number;
|
||||
flex?: boolean;
|
||||
grid?: boolean;
|
||||
cols?: number;
|
||||
gap?: number;
|
||||
center?: boolean;
|
||||
}
|
||||
|
||||
export const Box = React.forwardRef<HTMLDivElement, BoxProps>(({
|
||||
mt,
|
||||
mb,
|
||||
p,
|
||||
flex,
|
||||
grid,
|
||||
cols,
|
||||
gap,
|
||||
center,
|
||||
style,
|
||||
...props
|
||||
}, ref) => {
|
||||
const computedStyle: React.CSSProperties = {
|
||||
...(mt && { marginTop: `${mt * 0.25}rem` }),
|
||||
...(mb && { marginBottom: `${mb * 0.25}rem` }),
|
||||
...(p && { padding: `${p * 0.25}rem` }),
|
||||
...(flex && { display: 'flex' }),
|
||||
...(grid && {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols || 1}, minmax(0, 1fr))`,
|
||||
gap: gap ? `${gap * 0.25}rem` : undefined
|
||||
}),
|
||||
...(center && {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}),
|
||||
...style
|
||||
};
|
||||
|
||||
return <div ref={ref} style={computedStyle} {...props} />;
|
||||
});
|
||||
|
||||
// Usage:
|
||||
const MyComponent = () => {
|
||||
return (
|
||||
<Box flex center p={4} mt={2}>
|
||||
<Box grid cols={2} gap={4}>
|
||||
<div>Column 1</div>
|
||||
<div>Column 2</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
0
src/components/ui/card - Copy - Copy.tsx
Normal file
0
src/components/ui/card - Copy - Copy.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
84
src/core/dynamic.tsx
Normal file
84
src/core/dynamic.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// src/core/dynamic.tsx
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
ComponentType,
|
||||
FC,
|
||||
ComponentProps,
|
||||
ReactElement
|
||||
} from 'react';
|
||||
|
||||
interface DynamicOptions {
|
||||
loading?: ComponentType;
|
||||
error?: ComponentType<{ error: Error }>;
|
||||
}
|
||||
|
||||
type ImportFunction<T> = () => Promise<{ default: T }>;
|
||||
|
||||
const DefaultError: FC<{ error: Error }> = ({ error }): ReactElement => {
|
||||
return (
|
||||
<div className="dynamic-import-error">
|
||||
Error loading component: {error.message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultLoading: FC = (): ReactElement => {
|
||||
return <div className="dynamic-loading">Loading...</div>;
|
||||
};
|
||||
|
||||
export function dynamic<T extends ComponentType<any>>(
|
||||
importFn: ImportFunction<T>,
|
||||
options: DynamicOptions = {}
|
||||
): FC<ComponentProps<T>> {
|
||||
const LoadingComponent: ComponentType = options.loading || DefaultLoading;
|
||||
const ErrorComponent: ComponentType<{ error: Error }> = options.error || DefaultError;
|
||||
|
||||
const DynamicComponent: FC<ComponentProps<T>> = (props): ReactElement => {
|
||||
const [Component, setComponent] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadComponent = async (): Promise<void> => {
|
||||
try {
|
||||
const module = await importFn();
|
||||
if (mounted) {
|
||||
setComponent(() => module.default);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to load component'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadComponent();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent error={error} />;
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
return DynamicComponent;
|
||||
}
|
||||
|
||||
// Version for named exports
|
||||
export function dynamicNamed<T extends ComponentType<any>>(
|
||||
importFn: () => Promise<T>,
|
||||
options: DynamicOptions = {}
|
||||
): FC<ComponentProps<T>> {
|
||||
return dynamic(
|
||||
async () => ({ default: await importFn() }),
|
||||
options
|
||||
);
|
||||
}
|
||||
47
src/core/registry.tsx
Normal file
47
src/core/registry.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// src/core/registry.tsx
|
||||
import React, { FC, ComponentProps } from 'react';
|
||||
import { dynamic, dynamicNamed } from './dynamic';
|
||||
|
||||
interface Registry {
|
||||
PieChart: FC<ComponentProps<any>>;
|
||||
Chart: FC<ComponentProps<any>>;
|
||||
ForceGraph2D: FC<ComponentProps<any>>;
|
||||
[key: string]: FC<ComponentProps<any>>; // Add index signature
|
||||
Pie: FC<ComponentProps<any>>;
|
||||
Cell: FC<ComponentProps<any>>;
|
||||
ResponsiveContainer: FC<ComponentProps<any>>;
|
||||
Tooltip: FC<ComponentProps<any>>;
|
||||
Legend: FC<ComponentProps<any>>;
|
||||
}
|
||||
|
||||
export const ComponentRegistry: Registry = {
|
||||
// For named exports from recharts, use dynamicNamed
|
||||
PieChart: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.PieChart)
|
||||
),
|
||||
Pie: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.Pie)
|
||||
),
|
||||
Cell: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.Cell)
|
||||
),
|
||||
ResponsiveContainer: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.ResponsiveContainer)
|
||||
),
|
||||
Tooltip: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.Tooltip)
|
||||
),
|
||||
Legend: dynamicNamed(() =>
|
||||
import('recharts').then(mod => mod.Legend)
|
||||
),
|
||||
|
||||
// For components with default exports, use dynamic
|
||||
Chart: dynamic(() => import('react-apexcharts')),
|
||||
ForceGraph2D: dynamicNamed(() =>
|
||||
import('react-force-graph').then(mod => mod.ForceGraph2D)
|
||||
)
|
||||
};
|
||||
|
||||
export const getRegisteredComponent = (name: string): FC<ComponentProps<any>> | null => {
|
||||
return ComponentRegistry[name] || null;
|
||||
};
|
||||
212
src/core/scope.ts
Normal file
212
src/core/scope.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// src/core/scope.ts
|
||||
import React from 'react';
|
||||
import { App } from 'obsidian';
|
||||
|
||||
interface ScopeOptions {
|
||||
app?: App;
|
||||
parentScope?: ComponentScope;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export class ComponentScope {
|
||||
private static instances = new Map<string, ComponentScope>();
|
||||
private static globalScope: ComponentScope | null = null;
|
||||
|
||||
private variables = new Map<string, any>();
|
||||
private children = new Set<ComponentScope>();
|
||||
private readonly defaultScope: Record<string, any>;
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly options: ScopeOptions = {}
|
||||
) {
|
||||
// Initialize default React scope
|
||||
this.defaultScope = {
|
||||
React,
|
||||
useState: React.useState,
|
||||
useEffect: React.useEffect,
|
||||
useRef: React.useRef,
|
||||
useMemo: React.useMemo,
|
||||
useCallback: React.useCallback,
|
||||
useContext: React.useContext,
|
||||
// Add Obsidian app if available
|
||||
app: options.app,
|
||||
};
|
||||
|
||||
if (options.isGlobal) {
|
||||
ComponentScope.globalScope = this;
|
||||
} else if (options.parentScope) {
|
||||
options.parentScope.addChild(this);
|
||||
}
|
||||
|
||||
ComponentScope.instances.set(id, this);
|
||||
}
|
||||
|
||||
// Get a value from scope
|
||||
get(name: string): any {
|
||||
// Check local variables first
|
||||
if (this.variables.has(name)) {
|
||||
return this.variables.get(name);
|
||||
}
|
||||
|
||||
// Check default scope
|
||||
if (name in this.defaultScope) {
|
||||
return this.defaultScope[name];
|
||||
}
|
||||
|
||||
// Check parent scope
|
||||
if (this.options.parentScope) {
|
||||
return this.options.parentScope.get(name);
|
||||
}
|
||||
|
||||
// Check global scope as last resort
|
||||
if (!this.options.isGlobal && ComponentScope.globalScope) {
|
||||
return ComponentScope.globalScope.get(name);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Set a value in scope
|
||||
set(name: string, value: any): void {
|
||||
this.variables.set(name, value);
|
||||
}
|
||||
|
||||
// Remove a value from scope
|
||||
delete(name: string): boolean {
|
||||
return this.variables.delete(name);
|
||||
}
|
||||
|
||||
// Check if scope has a variable
|
||||
has(name: string): boolean {
|
||||
return (
|
||||
this.variables.has(name) ||
|
||||
name in this.defaultScope ||
|
||||
(this.options.parentScope?.has(name) ?? false) ||
|
||||
(!this.options.isGlobal && (ComponentScope.globalScope?.has(name) ?? false))
|
||||
);
|
||||
}
|
||||
|
||||
// Add a child scope
|
||||
private addChild(scope: ComponentScope): void {
|
||||
this.children.add(scope);
|
||||
}
|
||||
|
||||
// Remove a child scope
|
||||
private removeChild(scope: ComponentScope): void {
|
||||
this.children.delete(scope);
|
||||
}
|
||||
|
||||
// Clear all variables in this scope
|
||||
clear(): void {
|
||||
this.variables.clear();
|
||||
}
|
||||
|
||||
// Dispose of this scope
|
||||
dispose(): void {
|
||||
if (this.options.parentScope) {
|
||||
this.options.parentScope.removeChild(this);
|
||||
}
|
||||
ComponentScope.instances.delete(this.id);
|
||||
this.clear();
|
||||
|
||||
// Dispose of all children
|
||||
this.children.forEach(child => child.dispose());
|
||||
this.children.clear();
|
||||
}
|
||||
|
||||
// Get all variables in this scope (including inherited)
|
||||
getAllVariables(): Record<string, any> {
|
||||
const variables: Record<string, any> = {
|
||||
...this.defaultScope,
|
||||
...Object.fromEntries(this.variables)
|
||||
};
|
||||
|
||||
if (this.options.parentScope) {
|
||||
return {
|
||||
...this.options.parentScope.getAllVariables(),
|
||||
...variables
|
||||
};
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
// Static methods for scope management
|
||||
static getScope(id: string): ComponentScope | undefined {
|
||||
return this.instances.get(id);
|
||||
}
|
||||
|
||||
static createScope(id: string, options: ScopeOptions = {}): ComponentScope {
|
||||
if (this.instances.has(id)) {
|
||||
throw new Error(`Scope with id "${id}" already exists`);
|
||||
}
|
||||
return new ComponentScope(id, options);
|
||||
}
|
||||
|
||||
static getOrCreateScope(id: string, options: ScopeOptions = {}): ComponentScope {
|
||||
return this.instances.get(id) || new ComponentScope(id, options);
|
||||
}
|
||||
|
||||
static disposeScope(id: string): void {
|
||||
const scope = this.instances.get(id);
|
||||
if (scope) {
|
||||
scope.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a hook for React components to access scope
|
||||
static createScopeHook(scopeId: string) {
|
||||
return function useScopeValue(name: string) {
|
||||
const scope = ComponentScope.getScope(scopeId);
|
||||
if (!scope) {
|
||||
throw new Error(`Scope "${scopeId}" not found`);
|
||||
}
|
||||
return scope.get(name);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a new component scope
|
||||
export function createComponentScope(
|
||||
id: string,
|
||||
app?: App,
|
||||
parentScope?: ComponentScope
|
||||
): ComponentScope {
|
||||
return new ComponentScope(id, { app, parentScope });
|
||||
}
|
||||
|
||||
// React hook for accessing scope
|
||||
export function useComponentScope(scopeId: string) {
|
||||
return React.useMemo(() => {
|
||||
const scope = ComponentScope.getScope(scopeId);
|
||||
if (!scope) {
|
||||
throw new Error(`Scope "${scopeId}" not found`);
|
||||
}
|
||||
return scope;
|
||||
}, [scopeId]);
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
/*
|
||||
// Create a global scope
|
||||
const globalScope = new ComponentScope('global', { isGlobal: true });
|
||||
globalScope.set('sharedValue', 42);
|
||||
|
||||
// Create a component scope
|
||||
const componentScope = createComponentScope('component-1', app);
|
||||
componentScope.set('localValue', 'hello');
|
||||
|
||||
// In a React component:
|
||||
function MyComponent() {
|
||||
const scope = useComponentScope('component-1');
|
||||
const localValue = scope.get('localValue');
|
||||
const sharedValue = scope.get('sharedValue');
|
||||
|
||||
return (
|
||||
<div>
|
||||
Local: {localValue}, Shared: {sharedValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
*/
|
||||
109
src/core/storage.ts
Normal file
109
src/core/storage.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// src/core/storage.tsx
|
||||
import { MarketDataService, MarketData } from '../services/marketDataService';
|
||||
import { Plugin, TFile,parseYaml,stringifyYaml } from 'obsidian';
|
||||
export class StorageManager {
|
||||
private plugin: Plugin;
|
||||
private cache: Map<string, any>;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
private getCacheKey(noteFile: TFile, key: string): string {
|
||||
return `${noteFile.path}:${key}`;
|
||||
}
|
||||
|
||||
private async getFrontmatter(noteFile: TFile) {
|
||||
const metadata = this.plugin.app.metadataCache.getFileCache(noteFile)?.frontmatter;
|
||||
return metadata?.react_data || {};
|
||||
}
|
||||
async getMarketData(symbol: string, interval: string, range: string, noteFile: TFile): Promise<MarketData[]> {
|
||||
const cacheKey = `market-data-${symbol}-${interval}-${range}`;
|
||||
|
||||
// Get from frontmatter
|
||||
const frontmatter = await this.getFrontmatter(noteFile);
|
||||
const cached = frontmatter[cacheKey];
|
||||
|
||||
// Check if we have cached data and if it's still fresh (24 hours)
|
||||
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Fetch new data
|
||||
const data = (await MarketDataService.fetchHistoricalData(symbol)).data;
|
||||
|
||||
// Cache the data using existing set method
|
||||
await this.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}, noteFile);
|
||||
|
||||
return data;
|
||||
}
|
||||
async get<T>(key: string, defaultValue: T, noteFile: TFile): Promise<T> {
|
||||
const cacheKey = this.getCacheKey(noteFile, key);
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Get from frontmatter
|
||||
const frontmatter = await this.getFrontmatter(noteFile);
|
||||
const value = frontmatter[key] ?? defaultValue;
|
||||
|
||||
// Update cache
|
||||
this.cache.set(cacheKey, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, noteFile: TFile): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(noteFile, key);
|
||||
this.cache.set(cacheKey, value);
|
||||
|
||||
const content = await this.plugin.app.vault.read(noteFile);
|
||||
const frontmatter = await this.getFrontmatter(noteFile);
|
||||
|
||||
// Prepare new frontmatter
|
||||
const newFrontmatter = {
|
||||
...frontmatter,
|
||||
[key]: value
|
||||
};
|
||||
|
||||
// Update the file with new frontmatter
|
||||
const updatedContent = this.updateFrontmatter(content, newFrontmatter);
|
||||
await this.plugin.app.vault.modify(noteFile, updatedContent);
|
||||
}
|
||||
|
||||
private updateFrontmatter(content: string, newData: any): string {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
|
||||
const frontmatterMatch = content.match(frontmatterRegex);
|
||||
|
||||
if (frontmatterMatch) {
|
||||
// Update existing frontmatter
|
||||
let frontmatter;
|
||||
try {
|
||||
frontmatter = parseYaml(frontmatterMatch[1]);
|
||||
} catch (e) {
|
||||
frontmatter = {};
|
||||
}
|
||||
|
||||
frontmatter.react_data = {
|
||||
...frontmatter.react_data,
|
||||
...newData
|
||||
};
|
||||
|
||||
const newFrontmatter = stringifyYaml(frontmatter);
|
||||
return content.replace(frontmatterRegex, `---\n${newFrontmatter}---`);
|
||||
} else {
|
||||
// Create new frontmatter
|
||||
const newFrontmatter = stringifyYaml({ react_data: newData });
|
||||
return `---\n${newFrontmatter}---\n\n${content}`;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
80
src/core/styles.ts
Normal file
80
src/core/styles.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// src/core/styles.ts
|
||||
export const componentStyles = {
|
||||
// Base component styles
|
||||
reactComponent: `
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
`,
|
||||
|
||||
// Button styles
|
||||
reactComponentButton: `
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-on-accent);
|
||||
background-color: var(--interactive-accent);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
`,
|
||||
|
||||
// Chart-specific styles
|
||||
chartContainer: (theme: 'dark' | 'light') => `
|
||||
background: ${theme === 'dark' ? '#1e293b' : '#ffffff'};
|
||||
color: ${theme === 'dark' ? '#94a3b8' : '#475569'};
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 0.5rem;
|
||||
min-height: 400px;
|
||||
`,
|
||||
|
||||
// List styles
|
||||
listContent: `
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
// Common utility classes that don't need runtime processing
|
||||
export const utilityClasses = {
|
||||
layout: {
|
||||
'w-full': 'width: 100%;',
|
||||
'grid': 'display: grid;',
|
||||
'grid-cols-2': 'grid-template-columns: repeat(2, minmax(0, 1fr));',
|
||||
'gap-4': 'gap: 1rem;',
|
||||
},
|
||||
spacing: {
|
||||
'mt-4': 'margin-top: 1rem;',
|
||||
'mb-2': 'margin-bottom: 0.5rem;',
|
||||
'p-4': 'padding: 1rem;',
|
||||
},
|
||||
colors: {
|
||||
'text-green-500': 'color: var(--color-green);',
|
||||
'text-red-500': 'color: var(--color-red);',
|
||||
'text-blue-500': 'color: var(--interactive-accent);',
|
||||
}
|
||||
};
|
||||
|
||||
// Theme-aware chart configuration
|
||||
export const getChartConfig = (theme: 'dark' | 'light') => ({
|
||||
layout: {
|
||||
background: {
|
||||
color: theme === 'dark' ? '#1e293b' : '#ffffff'
|
||||
},
|
||||
textColor: theme === 'dark' ? '#94a3b8' : '#475569',
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: theme === 'dark' ? '#334155' : '#e2e8f0'
|
||||
},
|
||||
horzLines: {
|
||||
color: theme === 'dark' ? '#334155' : '#e2e8f0'
|
||||
},
|
||||
}
|
||||
});
|
||||
34
src/core/tailwind.ts
Normal file
34
src/core/tailwind.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// src/core/tailwind.ts
|
||||
import postcss from 'postcss';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export class TailwindProcessor {
|
||||
private config: any;
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
// Disable preflight to avoid conflicts with Obsidian
|
||||
corePlugins: {
|
||||
preflight: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Process Tailwind CSS
|
||||
async process(css: string): Promise<string> {
|
||||
const result = await postcss([
|
||||
tailwindcss(this.config),
|
||||
autoprefixer
|
||||
]).process(css, {
|
||||
from: undefined
|
||||
});
|
||||
|
||||
return result.css;
|
||||
}
|
||||
}
|
||||
|
||||
205
src/core/transformer.ts
Normal file
205
src/core/transformer.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// src/core/transformer.ts
|
||||
import { transform as babelTransform } from '@babel/standalone';
|
||||
|
||||
interface TransformOptions {
|
||||
scope?: Record<string, any>;
|
||||
filename?: string;
|
||||
isTypeScript?: boolean;
|
||||
isInline?: boolean;
|
||||
}
|
||||
|
||||
interface TransformResult {
|
||||
code: string;
|
||||
component: React.ComponentType;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ComponentTransformer {
|
||||
private static defaultScope = {
|
||||
React: window.React,
|
||||
useState: window.React.useState,
|
||||
useEffect: window.React.useEffect,
|
||||
useRef: window.React.useRef,
|
||||
useMemo: window.React.useMemo,
|
||||
useCallback: window.React.useCallback,
|
||||
};
|
||||
|
||||
static async transform(code: string, options: TransformOptions = {}): Promise<TransformResult> {
|
||||
try {
|
||||
// Prepare the code
|
||||
const processedCode = this.preprocessCode(code, options);
|
||||
|
||||
// Transform the code using Babel
|
||||
const transformed = await this.babelTransform(processedCode, options);
|
||||
|
||||
// Create the component
|
||||
const component = await this.createComponent(transformed, options);
|
||||
|
||||
return {
|
||||
code: transformed,
|
||||
component
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transformation error:', error);
|
||||
throw this.enhanceError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static preprocessCode(code: string, options: TransformOptions): string {
|
||||
// Remove imports and exports first
|
||||
code = code.replace(/import\s+.*?['"];?\s*$/gm, '');
|
||||
code = code.replace(/import\s*{[^}]*}\s*from\s*['"][^'"]*['"];?\s*$/gm, '');
|
||||
code = code.replace(/import\s*\([^)]*\);?\s*$/gm, '');
|
||||
code = code.replace(/export\s+default\s+/, '');
|
||||
code = code.replace(/export\s+const\s+/, 'const ');
|
||||
code = code.replace(/export\s+function\s+/, 'function ');
|
||||
code = code.replace(/export\s+class\s+/, 'class ');
|
||||
|
||||
// Handle any named component/function conversion to Component
|
||||
const namedComponentMatch = code.match(/const\s+(\w+)\s*=\s*\(\)\s*=>/);
|
||||
if (namedComponentMatch) {
|
||||
code = code.replace(
|
||||
`const ${namedComponentMatch[1]} = () =>`,
|
||||
'const Component = () =>'
|
||||
);
|
||||
} else if (!code.includes('const Component =') && !code.includes('function Component')) {
|
||||
code = `const Component = () => ${code}`;
|
||||
}
|
||||
|
||||
return `
|
||||
try {
|
||||
${code}
|
||||
} catch (error) {
|
||||
console.error('Error in component code:', error);
|
||||
throw error;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private static async babelTransform(code: string, options: TransformOptions): Promise<string> {
|
||||
const transformed = babelTransform(code, {
|
||||
presets: [
|
||||
'react',
|
||||
...(options.isTypeScript ? ['typescript'] : [])
|
||||
] as string[],
|
||||
filename: options.filename || 'component.jsx',
|
||||
sourceType: 'module',
|
||||
configFile: false,
|
||||
babelrc: false,
|
||||
plugins: [
|
||||
// Add any custom plugins here
|
||||
this.createScopePlugin(options.scope)
|
||||
]
|
||||
});
|
||||
|
||||
return transformed.code;
|
||||
}
|
||||
|
||||
private static async createComponent(code: string, options: TransformOptions): Promise<React.ComponentType> {
|
||||
// Create the scope with defaults and custom additions
|
||||
const scope = {
|
||||
...this.defaultScope,
|
||||
...options.scope
|
||||
};
|
||||
|
||||
// Create the component function
|
||||
const componentFn = new Function(
|
||||
...Object.keys(scope),
|
||||
`
|
||||
${code}
|
||||
return typeof Component !== 'undefined'
|
||||
? Component
|
||||
: () => null;
|
||||
`
|
||||
);
|
||||
|
||||
try {
|
||||
// Execute with scope
|
||||
return componentFn(...Object.values(scope));
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating component: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static createScopePlugin(customScope: Record<string, any> = {}) {
|
||||
// Custom babel plugin to handle scope
|
||||
return {
|
||||
visitor: {
|
||||
Identifier(path: any) {
|
||||
const name = path.node.name;
|
||||
if (
|
||||
path.scope.hasBinding(name) ||
|
||||
this.defaultScope.hasOwnProperty(name) ||
|
||||
customScope.hasOwnProperty(name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace unknown identifiers with scoped access
|
||||
path.replaceWith(
|
||||
path.scope.buildUndefinedNode()
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static getImports(): string {
|
||||
return `
|
||||
// Add any necessary imports here
|
||||
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
||||
`;
|
||||
}
|
||||
|
||||
private static getExports(): string {
|
||||
return `
|
||||
if (typeof Component === 'undefined') {
|
||||
throw new Error('No component defined');
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private static stripTypeAnnotations(code: string): string {
|
||||
// Basic TypeScript stripping - in a real implementation,
|
||||
// you might want to use a proper TS transformer
|
||||
return code
|
||||
.replace(/:\s*[A-Za-z<>[\]]+/g, '')
|
||||
.replace(/<[A-Za-z,\s]+>/g, '');
|
||||
}
|
||||
|
||||
private static enhanceError(error: Error): Error {
|
||||
// Enhance error messages for better debugging
|
||||
if (error.message.includes('Component')) {
|
||||
return new Error(`Component Error: ${error.message}`);
|
||||
}
|
||||
if (error.message.includes('React')) {
|
||||
return new Error(`React Error: ${error.message}`);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// Utility method for checking if code is TypeScript
|
||||
static isTypeScript(code: string): boolean {
|
||||
return /:\s*[A-Za-z]+/.test(code) || /<[A-Za-z,\s]+>/.test(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
/*
|
||||
const code = `
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<button onClick={() => setCount(c => c + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
`;
|
||||
|
||||
const { component: Counter } = await ComponentTransformer.transform(code, {
|
||||
scope: {
|
||||
// Additional scope variables
|
||||
customVar: 'value'
|
||||
},
|
||||
isInline: true
|
||||
});
|
||||
*/
|
||||
98
src/core/useMarketData.tsx
Normal file
98
src/core/useMarketData.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// src/hooks/useMarketData.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TFile } from 'obsidian';
|
||||
import { StorageManager } from '../core/storage';
|
||||
import { MarketDataService } from 'src/services/marketDataService';
|
||||
import { MarketData, MarketDataCache } from '../services/marketDataService';
|
||||
|
||||
interface UseMarketDataResult {
|
||||
data: MarketData[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
export function useMarketData(
|
||||
symbol: string,
|
||||
interval: string,
|
||||
range: string,
|
||||
storage: StorageManager,
|
||||
noteFile: TFile
|
||||
) {
|
||||
const [data, setData] = useState<MarketData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const marketData = await storage.getMarketData(
|
||||
symbol,
|
||||
interval,
|
||||
range,
|
||||
noteFile
|
||||
);
|
||||
setData(marketData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [symbol, interval, range, noteFile, storage]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
export function createMarketDataHook(storage: StorageManager, noteFile: TFile) {
|
||||
return function useMarketData(
|
||||
symbol: string,
|
||||
interval: string,
|
||||
range: string
|
||||
): UseMarketDataResult {
|
||||
const [data, setData] = useState<MarketData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const cacheKey = `market-data-${symbol}-${interval}-${range}`;
|
||||
|
||||
// Try to get from storage first
|
||||
const cached = await storage.get<MarketDataCache | null>(
|
||||
cacheKey,
|
||||
null,
|
||||
noteFile
|
||||
);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
|
||||
setData(cached.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
const {data:freshData} = await MarketDataService.fetchHistoricalData(symbol);
|
||||
|
||||
// Cache the new data
|
||||
await storage.set(
|
||||
cacheKey,
|
||||
{ data: freshData, timestamp: Date.now() },
|
||||
noteFile
|
||||
);
|
||||
|
||||
setData(freshData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [symbol, interval, range]);
|
||||
|
||||
return { data, loading, error };
|
||||
};
|
||||
}
|
||||
27
src/hooks/useStorage.ts
Normal file
27
src/hooks/useStorage.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// src/hooks/useStorage.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { TFile } from 'obsidian';
|
||||
import { StorageManager } from '../core/storage';
|
||||
|
||||
export function useStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
storage: StorageManager,
|
||||
noteFile: TFile
|
||||
): [T, (value: T | ((prev: T) => T)) => Promise<void>] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
|
||||
// Load initial value
|
||||
useEffect(() => {
|
||||
storage.get(key, defaultValue, noteFile).then(setValue);
|
||||
}, [key, defaultValue, noteFile]);
|
||||
|
||||
// Update function
|
||||
const updateValue = async (newValue: T | ((prev: T) => T)) => {
|
||||
const actualNewValue = newValue instanceof Function ? newValue(value) : newValue;
|
||||
setValue(actualNewValue);
|
||||
await storage.set(key, actualNewValue, noteFile);
|
||||
};
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//src/lib/util.ts
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
793
src/services/OrderBlockAnalysis.ts
Normal file
793
src/services/OrderBlockAnalysis.ts
Normal file
|
|
@ -0,0 +1,793 @@
|
|||
// src/services/OrderBlockAnalysis.ts
|
||||
import { TFile } from 'obsidian';
|
||||
import { MarketDataService, MarketData } from './marketDataService';
|
||||
import { MarketDataStorage } from './marketDataStorage';
|
||||
|
||||
interface SwingPoint {
|
||||
time: number;
|
||||
price: number;
|
||||
type: 'high' | 'low';
|
||||
index: number;
|
||||
}
|
||||
interface TrendLeg {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
direction: 'up' | 'down' | 'sideways';
|
||||
strength: number;
|
||||
startPrice: number;
|
||||
endPrice: number;
|
||||
swingPoints: SwingPoint[];
|
||||
}
|
||||
interface OrderBlock {
|
||||
id: string;
|
||||
type: 'bullish' | 'bearish';
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
highPrice: number;
|
||||
lowPrice: number;
|
||||
volume: number;
|
||||
impulseMagnitude: number;
|
||||
priceValueGap: number;
|
||||
validationMetrics: {
|
||||
trendAlignment: boolean;
|
||||
hasBreakOfStructure: boolean;
|
||||
gapQuality: number; // 0-1 score
|
||||
impulseStrength: number; // Relative to average movement
|
||||
};
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
analysisTime: number;
|
||||
marketHours: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
marketData: MarketData[]; // Add this
|
||||
trendLegs: TrendLeg[]; // Add this
|
||||
trend: {
|
||||
direction: 'up' | 'down' | 'none';
|
||||
strength: number;
|
||||
swingPoints: SwingPoint[];
|
||||
};
|
||||
orderBlocks: OrderBlock[];
|
||||
}
|
||||
|
||||
export class OrderBlockAnalysisService {
|
||||
private static readonly MARKET_HOURS = {
|
||||
start: { hour: 9, minute: 30 },
|
||||
end: { hour: 16, minute: 0 }
|
||||
};
|
||||
// Analysis specific params
|
||||
private static readonly ANALYSIS_PARAMS = {
|
||||
minSwingPoints: 4,
|
||||
lookbackPeriods: 20,
|
||||
minImpulseStrength: 1.5,
|
||||
minGapSize: 0.1, // 10% of average candle size
|
||||
trendStrengthThreshold: 0.6
|
||||
};
|
||||
private static readonly TREND_PARAMS = {
|
||||
minSwingPoints: 2, // Reduced minimum swing points
|
||||
trendThreshold: 0.4, // Lowered threshold
|
||||
minMovementSize: 0.1, // Minimum % move
|
||||
swingPointConfirmation: 1, // Candles to confirm swing
|
||||
momentumThreshold: 0.6, // New: Momentum strength threshold
|
||||
priceChangeThreshold: 0.001 // New: Minimum price change (0.1%)
|
||||
};
|
||||
constructor(
|
||||
private storage: MarketDataStorage,
|
||||
private marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main analysis function for a given symbol and timeframe
|
||||
*/
|
||||
async analyzeOrderBlocks(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
noteFile: TFile
|
||||
): Promise<AnalysisResult> {
|
||||
try{
|
||||
const today = new Date();
|
||||
const startTime = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()-1,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.start.hour,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.start.minute
|
||||
).getTime();
|
||||
|
||||
const endTime = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()-1,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.end.hour,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.end.minute
|
||||
).getTime();
|
||||
|
||||
const config = {
|
||||
interval: timeframe as any,
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
|
||||
// Use the marketDataService instance for fetching
|
||||
const data = await MarketDataService.getMarketData(symbol, config, this.storage);
|
||||
|
||||
// Apply market hours filter
|
||||
const marketHoursData = this.filterMarketHours(data);
|
||||
|
||||
const trend = this.analyzeTrend(marketHoursData);
|
||||
const orderBlocks = this.findOrderBlocks(marketHoursData, trend);
|
||||
|
||||
return {
|
||||
symbol,
|
||||
timeframe,
|
||||
analysisTime: Date.now(),
|
||||
marketHours: {
|
||||
start: startTime,
|
||||
end: endTime
|
||||
},
|
||||
marketData: marketHoursData,
|
||||
trendLegs: this.analyzeTrendLegs(marketHoursData),
|
||||
trend,
|
||||
orderBlocks
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error analyzing order blocks:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data for market hours only
|
||||
*/
|
||||
private filterMarketHours(data: MarketData[]): MarketData[] {
|
||||
return data.filter(candle => {
|
||||
const date = new Date(candle.time);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
if (hours < OrderBlockAnalysisService.MARKET_HOURS.start.hour ||
|
||||
hours > OrderBlockAnalysisService.MARKET_HOURS.end.hour) return false;
|
||||
|
||||
if (hours === OrderBlockAnalysisService.MARKET_HOURS.start.hour &&
|
||||
minutes < OrderBlockAnalysisService.MARKET_HOURS.start.minute) return false;
|
||||
|
||||
if (hours === OrderBlockAnalysisService.MARKET_HOURS.end.hour &&
|
||||
minutes >= OrderBlockAnalysisService.MARKET_HOURS.end.minute) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced trend analysis that considers both structure and momentum
|
||||
*/
|
||||
private analyzeTrend(data: MarketData[]): {
|
||||
direction: 'up' | 'down' | 'none';
|
||||
strength: number;
|
||||
swingPoints: SwingPoint[];
|
||||
} {
|
||||
if (data.length < 10) return { direction: 'none', strength: 0, swingPoints: [] };
|
||||
|
||||
const atr = this.calculateATR(data, 14);
|
||||
const swingPoints = this.findSignificantSwings(data, atr);
|
||||
|
||||
// Calculate overall momentum
|
||||
const momentum = this.calculateMomentum(data);
|
||||
|
||||
// Calculate price movement
|
||||
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
|
||||
const absolutePriceChange = Math.abs(priceChange);
|
||||
|
||||
// Only proceed with detailed analysis if we have significant price movement
|
||||
if (absolutePriceChange < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
|
||||
return { direction: 'none', strength: 0, swingPoints };
|
||||
}
|
||||
|
||||
// Get structural analysis
|
||||
const structure = this.analyzeTrendStructure(swingPoints, atr);
|
||||
|
||||
// Combine structural and momentum analysis
|
||||
const direction = this.determineOverallTrend(structure, momentum, priceChange);
|
||||
const strength = this.calculateOverallStrength(structure.strength, momentum.strength);
|
||||
|
||||
return {
|
||||
direction,
|
||||
strength,
|
||||
swingPoints
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Average True Range
|
||||
*/
|
||||
private calculateATR(data: MarketData[], period: number): number {
|
||||
if (data.length < period) return 0;
|
||||
|
||||
let tr = [];
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const high = data[i].high;
|
||||
const low = data[i].low;
|
||||
const prevClose = data[i-1].close;
|
||||
|
||||
tr.push(Math.max(
|
||||
high - low,
|
||||
Math.abs(high - prevClose),
|
||||
Math.abs(low - prevClose)
|
||||
));
|
||||
}
|
||||
|
||||
// Calculate simple moving average of TR
|
||||
const atr = tr.slice(-period).reduce((sum, val) => sum + val, 0) / period;
|
||||
return atr;
|
||||
}
|
||||
/**
|
||||
* Calculate price momentum
|
||||
*/
|
||||
private calculateMomentum(data: MarketData[]): { direction: 'up' | 'down'; strength: number } {
|
||||
const closes = data.map(d => d.close);
|
||||
let upMoves = 0;
|
||||
let downMoves = 0;
|
||||
|
||||
// Count consecutive moves
|
||||
for (let i = 1; i < closes.length; i++) {
|
||||
if (closes[i] > closes[i - 1]) upMoves++;
|
||||
if (closes[i] < closes[i - 1]) downMoves++;
|
||||
}
|
||||
|
||||
const totalMoves = upMoves + downMoves;
|
||||
const upStrength = upMoves / totalMoves;
|
||||
const downStrength = downMoves / totalMoves;
|
||||
|
||||
return {
|
||||
direction: upStrength > downStrength ? 'up' : 'down',
|
||||
strength: Math.max(upStrength, downStrength)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine overall trend combining structure and momentum
|
||||
*/
|
||||
private determineOverallTrend(
|
||||
structure: { direction: 'up' | 'down' | 'none'; strength: number },
|
||||
momentum: { direction: 'up' | 'down'; strength: number },
|
||||
priceChange: number
|
||||
): 'up' | 'down' | 'none' {
|
||||
// Strong momentum overrides structure
|
||||
if (momentum.strength > OrderBlockAnalysisService.TREND_PARAMS.momentumThreshold) {
|
||||
return momentum.direction;
|
||||
}
|
||||
|
||||
// Strong structure with confirming price change
|
||||
if (structure.direction !== 'none' &&
|
||||
Math.sign(priceChange) === (structure.direction === 'up' ? 1 : -1)) {
|
||||
return structure.direction;
|
||||
}
|
||||
|
||||
// Default to momentum direction if price change confirms it
|
||||
if (Math.sign(priceChange) === (momentum.direction === 'up' ? 1 : -1)) {
|
||||
return momentum.direction;
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall trend strength
|
||||
*/
|
||||
private calculateOverallStrength(structureStrength: number, momentumStrength: number): number {
|
||||
return (structureStrength * 0.6 + momentumStrength * 0.4);
|
||||
}
|
||||
/**
|
||||
* Find significant swing points using ATR for context
|
||||
*/
|
||||
private findSignificantSwings(data: MarketData[], atr: number): SwingPoint[] {
|
||||
const swingPoints: SwingPoint[] = [];
|
||||
const minMove = atr * 0.3; // Reduced from 0.5 to catch more potential swings
|
||||
|
||||
for (let i = 2; i < data.length - 2; i++) {
|
||||
const current = data[i];
|
||||
const before = data.slice(i - 2, i);
|
||||
const after = data.slice(i + 1, i + 3);
|
||||
|
||||
// Check for swing high with relaxed conditions
|
||||
if (before.every(c => c.high <= current.high) &&
|
||||
after[0].high < current.high) {
|
||||
swingPoints.push({
|
||||
type: 'high',
|
||||
price: current.high,
|
||||
time: current.time,
|
||||
index: i
|
||||
});
|
||||
}
|
||||
|
||||
// Check for swing low with relaxed conditions
|
||||
if (before.every(c => c.low >= current.low) &&
|
||||
after[0].low > current.low) {
|
||||
swingPoints.push({
|
||||
type: 'low',
|
||||
price: current.low,
|
||||
time: current.time,
|
||||
index: i
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return swingPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze trend structure using swing points
|
||||
*/
|
||||
private analyzeTrendStructure(
|
||||
swingPoints: SwingPoint[],
|
||||
atr: number
|
||||
): { direction: 'up' | 'down' | 'none'; strength: number; } {
|
||||
const highs = swingPoints.filter(p => p.type === 'high')
|
||||
.sort((a, b) => a.time - b.time);
|
||||
const lows = swingPoints.filter(p => p.type === 'low')
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
if (highs.length < 2 || lows.length < 2) {
|
||||
return { direction: 'none', strength: 0 };
|
||||
}
|
||||
|
||||
// Calculate trend metrics
|
||||
const hhSequence = this.calculateSequenceStrength(highs, 'up', atr);
|
||||
const lhSequence = this.calculateSequenceStrength(highs, 'down', atr);
|
||||
const hlSequence = this.calculateSequenceStrength(lows, 'up', atr);
|
||||
const llSequence = this.calculateSequenceStrength(lows, 'down', atr);
|
||||
|
||||
// Determine trend
|
||||
const upStrength = (hhSequence + hlSequence) / 2;
|
||||
const downStrength = (lhSequence + llSequence) / 2;
|
||||
|
||||
if (upStrength > OrderBlockAnalysisService.TREND_PARAMS.trendThreshold &&
|
||||
upStrength > downStrength) {
|
||||
return {
|
||||
direction: 'up',
|
||||
strength: upStrength
|
||||
};
|
||||
}
|
||||
|
||||
if (downStrength > OrderBlockAnalysisService.TREND_PARAMS.trendThreshold &&
|
||||
downStrength > upStrength) {
|
||||
return {
|
||||
direction: 'down',
|
||||
strength: downStrength
|
||||
};
|
||||
}
|
||||
|
||||
return { direction: 'none', strength: 0 };
|
||||
}
|
||||
private analyzeTrendLegs(data: MarketData[]): TrendLeg[] {
|
||||
const trendLegs: TrendLeg[] = [];
|
||||
const atr = this.calculateATR(data, 14);
|
||||
let currentLegStart = 0;
|
||||
|
||||
// Minimum number of candles to consider a valid trend leg
|
||||
const MIN_LEG_LENGTH = 5;
|
||||
|
||||
// Loop through data to identify trend changes
|
||||
for (let i = MIN_LEG_LENGTH; i < data.length - MIN_LEG_LENGTH; i++) {
|
||||
const currentSegment = data.slice(currentLegStart, i + 1);
|
||||
const nextSegment = data.slice(i - MIN_LEG_LENGTH, i + MIN_LEG_LENGTH);
|
||||
|
||||
// Check for trend change
|
||||
if (this.isTrendChange(currentSegment, nextSegment, atr)) {
|
||||
// Add completed trend leg
|
||||
if (i - currentLegStart >= MIN_LEG_LENGTH) {
|
||||
const legData = data.slice(currentLegStart, i);
|
||||
const trendLeg = this.analyzeSingleTrendLeg(legData, currentLegStart, i);
|
||||
trendLegs.push(trendLeg);
|
||||
}
|
||||
currentLegStart = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final trend leg
|
||||
if (data.length - currentLegStart >= MIN_LEG_LENGTH) {
|
||||
const finalLegData = data.slice(currentLegStart);
|
||||
const finalTrendLeg = this.analyzeSingleTrendLeg(
|
||||
finalLegData,
|
||||
currentLegStart,
|
||||
data.length - 1
|
||||
);
|
||||
trendLegs.push(finalTrendLeg);
|
||||
}
|
||||
|
||||
return trendLegs;
|
||||
}
|
||||
|
||||
private isTrendChange(
|
||||
currentSegment: MarketData[],
|
||||
nextSegment: MarketData[],
|
||||
atr: number
|
||||
): boolean {
|
||||
const currentDirection = this.determineTrendDirection(currentSegment);
|
||||
const nextDirection = this.determineTrendDirection(nextSegment);
|
||||
|
||||
// Check for direction change
|
||||
if (currentDirection !== nextDirection) {
|
||||
// Verify change is significant (> 1 ATR)
|
||||
const priceChange = Math.abs(
|
||||
nextSegment[nextSegment.length - 1].close - nextSegment[0].close
|
||||
);
|
||||
return priceChange > atr;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private analyzeSingleTrendLeg(
|
||||
data: MarketData[],
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): TrendLeg {
|
||||
const swingPoints = this.findSignificantSwings(data, this.calculateATR(data, 14));
|
||||
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
|
||||
const momentum = this.calculateMomentum(data);
|
||||
|
||||
// Determine trend direction
|
||||
let direction: 'up' | 'down' | 'sideways';
|
||||
if (Math.abs(priceChange) < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
|
||||
direction = 'sideways';
|
||||
} else {
|
||||
direction = priceChange > 0 ? 'up' : 'down';
|
||||
}
|
||||
|
||||
// Calculate trend strength based on:
|
||||
// 1. Price change magnitude
|
||||
// 2. Momentum consistency
|
||||
// 3. Swing point alignment
|
||||
const priceStrength = Math.min(Math.abs(priceChange) * 10, 1); // Scale price change
|
||||
const momentumStrength = momentum.strength;
|
||||
const swingStrength = this.calculateSwingAlignment(swingPoints, direction);
|
||||
|
||||
const strength = (
|
||||
priceStrength * 0.4 +
|
||||
momentumStrength * 0.4 +
|
||||
swingStrength * 0.2
|
||||
) * 100; // Convert to percentage
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
startTime: data[0].time,
|
||||
endTime: data[data.length - 1].time,
|
||||
direction,
|
||||
strength,
|
||||
startPrice: data[0].close,
|
||||
endPrice: data[data.length - 1].close,
|
||||
swingPoints
|
||||
};
|
||||
}
|
||||
|
||||
private calculateSwingAlignment(
|
||||
swingPoints: SwingPoint[],
|
||||
direction: 'up' | 'down' | 'sideways'
|
||||
): number {
|
||||
if (direction === 'sideways' || swingPoints.length < 2) return 0;
|
||||
|
||||
let aligned = 0;
|
||||
let total = 0;
|
||||
|
||||
for (let i = 1; i < swingPoints.length; i++) {
|
||||
if (direction === 'up') {
|
||||
if (swingPoints[i].price > swingPoints[i - 1].price) aligned++;
|
||||
} else {
|
||||
if (swingPoints[i].price < swingPoints[i - 1].price) aligned++;
|
||||
}
|
||||
total++;
|
||||
}
|
||||
|
||||
return total > 0 ? aligned / total : 0;
|
||||
}
|
||||
|
||||
private determineTrendDirection(data: MarketData[]): 'up' | 'down' | 'sideways' {
|
||||
const priceChange = (data[data.length - 1].close - data[0].close) / data[0].close;
|
||||
|
||||
if (Math.abs(priceChange) < OrderBlockAnalysisService.TREND_PARAMS.priceChangeThreshold) {
|
||||
return 'sideways';
|
||||
}
|
||||
return priceChange > 0 ? 'up' : 'down';
|
||||
}
|
||||
/**
|
||||
* Calculate sequence strength relative to ATR
|
||||
*/
|
||||
private calculateSequenceStrength(
|
||||
points: SwingPoint[],
|
||||
direction: 'up' | 'down',
|
||||
atr: number
|
||||
): number {
|
||||
if (points.length < 2) return 0;
|
||||
|
||||
let validMoves = 0;
|
||||
let totalMoves = 0;
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const move = direction === 'up'
|
||||
? points[i].price - points[i-1].price
|
||||
: points[i-1].price - points[i].price;
|
||||
|
||||
if (move > 0 && Math.abs(move) > atr * 0.5) {
|
||||
validMoves++;
|
||||
}
|
||||
totalMoves++;
|
||||
}
|
||||
|
||||
return totalMoves > 0 ? validMoves / totalMoves : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trend strength (0-1)
|
||||
*/
|
||||
private calculateTrendStrength(
|
||||
points: SwingPoint[],
|
||||
direction: 'up' | 'down'
|
||||
): number {
|
||||
if (points.length < 2) return 0;
|
||||
|
||||
let increasing = 0;
|
||||
let total = 0;
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const current = points[i].price;
|
||||
const previous = points[i - 1].price;
|
||||
|
||||
if (direction === 'up' && current > previous) increasing++;
|
||||
if (direction === 'down' && current < previous) increasing++;
|
||||
total++;
|
||||
}
|
||||
|
||||
return total > 0 ? increasing / total : 0;
|
||||
}
|
||||
/**
|
||||
* Find order blocks
|
||||
*/
|
||||
private findOrderBlocks(
|
||||
data: MarketData[],
|
||||
trend: { direction: 'up' | 'down' | 'none'; strength: number; }
|
||||
): OrderBlock[] {
|
||||
const orderBlocks: OrderBlock[] = [];
|
||||
|
||||
// Only proceed if we have a clear trend
|
||||
if (trend.direction === 'none' || trend.strength < OrderBlockAnalysisService.ANALYSIS_PARAMS.trendStrengthThreshold) {
|
||||
return orderBlocks;
|
||||
}
|
||||
|
||||
// Use a sliding window of 7 candles (3 before, current, 3 after)
|
||||
for (let i = 3; i < data.length - 3; i++) {
|
||||
const window = {
|
||||
before: data.slice(i - 3, i),
|
||||
current: data[i],
|
||||
after: data.slice(i + 1, i + 4)
|
||||
};
|
||||
|
||||
// Check for potential order block
|
||||
const orderBlock = this.validateOrderBlock(window, trend.direction);
|
||||
if (orderBlock) {
|
||||
orderBlocks.push(orderBlock);
|
||||
}
|
||||
}
|
||||
|
||||
return this.filterOverlappingBlocks(orderBlocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate potential order block
|
||||
*/
|
||||
private validateOrderBlock(
|
||||
window: {
|
||||
before: MarketData[];
|
||||
current: MarketData;
|
||||
after: MarketData[];
|
||||
},
|
||||
trendDirection: 'up' | 'down'
|
||||
): OrderBlock | null {
|
||||
const { before, current, after } = window;
|
||||
|
||||
// Check for bullish order block in uptrend
|
||||
if (trendDirection === 'up') {
|
||||
const isBearishCandle = current.close < current.open;
|
||||
const hasImpulsiveMove = this.hasImpulsiveMove(after, 'up');
|
||||
const pvg = this.calculatePriceValueGap([...before, current, ...after]);
|
||||
|
||||
if (isBearishCandle && hasImpulsiveMove && pvg > OrderBlockAnalysisService.ANALYSIS_PARAMS.minGapSize) {
|
||||
return {
|
||||
id: `OB_${current.time}`,
|
||||
type: 'bullish',
|
||||
startTime: current.time,
|
||||
endTime: after[0].time,
|
||||
highPrice: current.high,
|
||||
lowPrice: current.low,
|
||||
volume: current.volume,
|
||||
impulseMagnitude: this.calculateImpulseMagnitude([...before, current, ...after]),
|
||||
priceValueGap: pvg,
|
||||
validationMetrics: {
|
||||
trendAlignment: true,
|
||||
hasBreakOfStructure: this.hasBreakOfStructure([...before, current, ...after], 'up'),
|
||||
gapQuality: this.calculateGapQuality(pvg, current),
|
||||
impulseStrength: this.calculateImpulseStrength(after, before)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for bearish order block in downtrend
|
||||
if (trendDirection === 'down') {
|
||||
const isBullishCandle = current.close > current.open;
|
||||
const hasImpulsiveMove = this.hasImpulsiveMove(after, 'down');
|
||||
const pvg = this.calculatePriceValueGap([...before, current, ...after]);
|
||||
|
||||
if (isBullishCandle && hasImpulsiveMove && pvg > OrderBlockAnalysisService.ANALYSIS_PARAMS.minGapSize) {
|
||||
return {
|
||||
id: `OB_${current.time}`,
|
||||
type: 'bearish',
|
||||
startTime: current.time,
|
||||
endTime: after[0].time,
|
||||
highPrice: current.high,
|
||||
lowPrice: current.low,
|
||||
volume: current.volume,
|
||||
impulseMagnitude: this.calculateImpulseMagnitude([...before, current, ...after]),
|
||||
priceValueGap: pvg,
|
||||
validationMetrics: {
|
||||
trendAlignment: true,
|
||||
hasBreakOfStructure: this.hasBreakOfStructure([...before, current, ...after], 'down'),
|
||||
gapQuality: this.calculateGapQuality(pvg, current),
|
||||
impulseStrength: this.calculateImpulseStrength(after, before)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate price value gap
|
||||
*/
|
||||
private calculatePriceValueGap(candles: MarketData[]): number {
|
||||
let maxGap = 0;
|
||||
|
||||
for (let i = 1; i < candles.length - 1; i++) {
|
||||
const current = candles[i];
|
||||
const next = candles[i + 1];
|
||||
|
||||
// Check for gap between candles
|
||||
if (next.low > current.high) {
|
||||
maxGap = Math.max(maxGap, next.low - current.high);
|
||||
}
|
||||
if (next.high < current.low) {
|
||||
maxGap = Math.max(maxGap, current.low - next.high);
|
||||
}
|
||||
}
|
||||
|
||||
return maxGap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for impulsive move
|
||||
*/
|
||||
private hasImpulsiveMove(candles: MarketData[], direction: 'up' | 'down'): boolean {
|
||||
const moves = candles.map(c => Math.abs(c.close - c.open));
|
||||
const avgMove = moves.reduce((sum, move) => sum + move, 0) / moves.length;
|
||||
|
||||
if (direction === 'up') {
|
||||
return candles[0].close > candles[0].open &&
|
||||
avgMove > OrderBlockAnalysisService.ANALYSIS_PARAMS.minImpulseStrength;
|
||||
} else {
|
||||
return candles[0].close < candles[0].open &&
|
||||
avgMove > OrderBlockAnalysisService.ANALYSIS_PARAMS.minImpulseStrength;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate impulse magnitude
|
||||
*/
|
||||
private calculateImpulseMagnitude(candles: MarketData[]): number {
|
||||
const moves = candles.map(c => ({
|
||||
move: Math.abs(c.close - c.open),
|
||||
volume: c.volume
|
||||
}));
|
||||
|
||||
// Volume-weighted average move
|
||||
const weightedMoves = moves.map(m => m.move * (m.volume / Math.max(...moves.map(x => x.volume))));
|
||||
return weightedMoves.reduce((sum, move) => sum + move, 0) / weightedMoves.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate gap quality (0-1)
|
||||
*/
|
||||
private calculateGapQuality(gap: number, candle: MarketData): number {
|
||||
const candleSize = Math.abs(candle.high - candle.low);
|
||||
return Math.min(gap / candleSize, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate impulse strength
|
||||
*/
|
||||
private calculateImpulseStrength(after: MarketData[], before: MarketData[]): number {
|
||||
const afterMoves = after.map(c => Math.abs(c.close - c.open));
|
||||
const beforeMoves = before.map(c => Math.abs(c.close - c.open));
|
||||
|
||||
const avgAfter = afterMoves.reduce((sum, move) => sum + move, 0) / afterMoves.length;
|
||||
const avgBefore = beforeMoves.reduce((sum, move) => sum + move, 0) / beforeMoves.length;
|
||||
|
||||
return avgAfter / avgBefore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for break of structure
|
||||
*/
|
||||
private hasBreakOfStructure(candles: MarketData[], direction: 'up' | 'down'): boolean {
|
||||
const middle = Math.floor(candles.length / 2);
|
||||
const before = candles.slice(0, middle);
|
||||
const after = candles.slice(middle);
|
||||
|
||||
if (direction === 'up') {
|
||||
const beforeHigh = Math.max(...before.map(c => c.high));
|
||||
const afterHigh = Math.max(...after.map(c => c.high));
|
||||
return afterHigh > beforeHigh;
|
||||
} else {
|
||||
const beforeLow = Math.min(...before.map(c => c.low));
|
||||
const afterLow = Math.min(...after.map(c => c.low));
|
||||
return afterLow < beforeLow;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter overlapping order blocks
|
||||
*/
|
||||
private filterOverlappingBlocks(blocks: OrderBlock[]): OrderBlock[] {
|
||||
return blocks.filter((block, index) => {
|
||||
// Check if this block overlaps with any stronger blocks
|
||||
const hasStrongerOverlap = blocks.some((otherBlock, otherIndex) => {
|
||||
if (index === otherIndex) return false;
|
||||
|
||||
const timeOverlap = block.startTime <= otherBlock.endTime &&
|
||||
block.endTime >= otherBlock.startTime;
|
||||
|
||||
const priceOverlap = block.lowPrice <= otherBlock.highPrice &&
|
||||
block.highPrice >= otherBlock.lowPrice;
|
||||
|
||||
return timeOverlap && priceOverlap &&
|
||||
otherBlock.validationMetrics.impulseStrength >
|
||||
block.validationMetrics.impulseStrength;
|
||||
});
|
||||
|
||||
return !hasStrongerOverlap;
|
||||
});
|
||||
}
|
||||
|
||||
private getTodayMarketOpen(): number {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
OrderBlockAnalysisService.MARKET_HOURS.start.hour,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.start.minute
|
||||
).getTime();
|
||||
}
|
||||
|
||||
private getTodayMarketClose(): number {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
OrderBlockAnalysisService.MARKET_HOURS.end.hour,
|
||||
OrderBlockAnalysisService.MARKET_HOURS.end.minute
|
||||
).getTime();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export type { AnalysisResult, OrderBlock, SwingPoint };
|
||||
459
src/services/marketDataService.ts
Normal file
459
src/services/marketDataService.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// src/services/marketDataService.ts
|
||||
import { error, time } from "console";
|
||||
import { MarketDataStorage } from "./marketDataStorage";
|
||||
import { TFile } from "obsidian";
|
||||
import dotenv from 'dotenv';
|
||||
// Initialize dotenv
|
||||
dotenv.config();
|
||||
|
||||
// Create a config object to manage environment variables
|
||||
const config = {
|
||||
primaryApiKey: process.env.ALPHA_VANTAGE_PRIMARY_KEY,
|
||||
secondaryApiKey: process.env.ALPHA_VANTAGE_SECONDARY_KEY,
|
||||
cacheDuration: parseInt(process.env.MARKET_DATA_CACHE_DURATION || '86400000'),
|
||||
timezone: process.env.TIMEZONE || 'US/Eastern',
|
||||
|
||||
// Validate environment variables
|
||||
validate() {
|
||||
if (!this.primaryApiKey) {
|
||||
throw new Error('ALPHA_VANTAGE_PRIMARY_KEY is not set in environment variables');
|
||||
}
|
||||
if (!this.secondaryApiKey) {
|
||||
throw new Error('ALPHA_VANTAGE_SECONDARY_KEY is not set in environment variables');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export interface MarketData {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
sma?: number; // Simple Moving Average
|
||||
ema?: number; // Exponential Moving Average
|
||||
rsi?: number; // Relative Strength Index
|
||||
}
|
||||
export interface MarketDataCache {
|
||||
data: MarketData[];
|
||||
timestamp: number;
|
||||
}
|
||||
export interface MarketHours {
|
||||
start: number; // 4:00 AM ET
|
||||
end: number; // 19:59 PM ET
|
||||
timeZone: string; // 'America/New_York'
|
||||
}
|
||||
export type IntervalType = '1min' | '5min' | '15min' | '30min' | '60min' | 'daily';
|
||||
|
||||
export interface TimeframeConfig {
|
||||
interval: IntervalType;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
data: MarketData[];
|
||||
timestamp: number;
|
||||
interval: IntervalType;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
timezone:string;
|
||||
}
|
||||
|
||||
export interface CacheMetadata {
|
||||
[symbol: string]: {
|
||||
[interval in IntervalType]?: CacheEntry[];
|
||||
};
|
||||
}
|
||||
export class MarketDataService {
|
||||
static async fetchHistoricalData(
|
||||
symbol: string,
|
||||
outputsize: 'full' | 'compact' = 'full'
|
||||
): Promise<{data:MarketData[],timezone: string}> {
|
||||
// Validate environment variables
|
||||
config.validate();
|
||||
|
||||
const url = `https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=${symbol}&outputsize=${outputsize}&apikey=${config.primaryApiKey}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (data['Error Message']) {
|
||||
throw new Error(data['Error Message']);
|
||||
}
|
||||
|
||||
const timezone = data['Meta Data']['6. Time Zone'];
|
||||
const timeSeries = data['Time Series (Daily)'];
|
||||
|
||||
const marketData= Object.entries(timeSeries).map(([date, values]: [string, any]) => ({
|
||||
time: new Date(date).getTime(),
|
||||
open: parseFloat(values['1. open']),
|
||||
high: parseFloat(values['2. high']),
|
||||
low: parseFloat(values['3. low']),
|
||||
close: parseFloat(values['4. close']),
|
||||
volume: parseInt(values['5. volume'])
|
||||
})).reverse();
|
||||
return{data:marketData,timezone}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchIntraday(
|
||||
symbol: string,
|
||||
interval: '1min' | '5min' | '15min' | '30min' | '60min' = '5min'
|
||||
): Promise<{data: MarketData[], timezone: string }> {
|
||||
// Validate environment variables
|
||||
config.validate();
|
||||
|
||||
const url = `https://www.alphavantage.co/query?function=TIME_SERIES_INTRADAY&symbol=${symbol}&interval=${interval}&outputsize=full&apikey=${config.secondaryApiKey}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
// Check for rate limit message
|
||||
/* if (data.Information && data.Information.includes('rate limit')) {
|
||||
throw new Error('API rate limit reached. Please try again in a minute.');
|
||||
}*/
|
||||
// Log full response to see structure
|
||||
console.log('Alpha Vantage Response:', data);
|
||||
|
||||
// Method 1: Log all top-level keys
|
||||
console.log('Top level keys:', Object.keys(data));
|
||||
const timezone = data['Meta Data']['5. Time Zone'];
|
||||
const timeSeriesKey = `Time Series (${interval})`;
|
||||
const timeSeries = data[timeSeriesKey];
|
||||
|
||||
if (!timeSeries) {
|
||||
throw new Error('No data returned from API. You may have exceeded the daily limit.');
|
||||
}
|
||||
|
||||
const marketData= Object.entries(timeSeries).map(([date, values]: [string, any]) => ({
|
||||
time: new Date(date).getTime(),
|
||||
open: parseFloat(values['1. open']),
|
||||
high: parseFloat(values['2. high']),
|
||||
low: parseFloat(values['3. low']),
|
||||
close: parseFloat(values['4. close']),
|
||||
volume: parseInt(values['5. volume'])
|
||||
})).reverse();
|
||||
|
||||
return { data: marketData, timezone}
|
||||
} catch (error) {
|
||||
console.error('Error fetching intraday data:', error);
|
||||
throw error;
|
||||
//For when we want to hide implementation details
|
||||
//throw new Error('Unable to fetch intraday data. Please try again later.');
|
||||
}
|
||||
}
|
||||
static readonly MARKET_HOURS: MarketHours = {
|
||||
start: 4 * 60, // 4:00 AM in minutes
|
||||
end: 20 * 60 - 1, // 19:59 PM in minutes
|
||||
timeZone: config.timezone
|
||||
};
|
||||
|
||||
// Add method for getting multiple timeframes in one call
|
||||
static async fetchMultiTimeframe(
|
||||
symbol: string,
|
||||
timeframes: Array<'1min' | '5min' | '15min' | '30min' | '60min' | 'daily'>
|
||||
): Promise<Record<string, MarketData[]>> {
|
||||
try {
|
||||
const results: Record<string, MarketData[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
timeframes.map(async (timeframe) => {
|
||||
if (timeframe === 'daily') {
|
||||
results[timeframe] = (await this.fetchHistoricalData(symbol)).data;
|
||||
} else {
|
||||
data:results[timeframe] = (await this.fetchIntraday(symbol, timeframe)).data;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error fetching multiple timeframes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private static INTERVALS_MINUTES = {
|
||||
'1min': 1,
|
||||
'5min': 5,
|
||||
'15min': 15,
|
||||
'30min': 30,
|
||||
'60min': 60,
|
||||
'daily': 1440
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
private static adjustTimeEndRequest(timestamp: number): number {
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
// If weekend, adjust to Friday
|
||||
if (day === 1&&hours<9) {date.setDate(date.getDate() - 3);date.setHours(19, 59, 0, 0);} // Monday -> Friday
|
||||
if (day === 0) {date.setDate(date.getDate() - 2); date.setHours(19, 59, 0, 0);} // Sunday -> Friday
|
||||
if (day === 6) {date.setDate(date.getDate() - 1);date.setHours(19, 59, 0, 0);} // Saturday -> Friday
|
||||
|
||||
// Set to 16:00 (4 PM) ET if after market close
|
||||
if (hours > 19|| hours<4 ) {
|
||||
date.setHours(19, 59, 0, 0);
|
||||
date.setDate(date.getDate() - hours<4?1:0);
|
||||
}
|
||||
return date.getTime();
|
||||
}
|
||||
private static doesCacheCover(cache: CacheEntry, request: TimeframeConfig): boolean {
|
||||
|
||||
// If cache's last data point is within 24 hours, consider it current
|
||||
const adjustedEndTime = this.adjustTimeEndRequest(
|
||||
cache.endTime +24*60*60*1000>= request.endTime ? cache.endTime : request.endTime
|
||||
);
|
||||
console.log('Request Time:', {
|
||||
|
||||
Time: new Date(adjustedEndTime).toLocaleString()
|
||||
});
|
||||
return cache.interval === request.interval &&
|
||||
cache.startTime <= request.startTime &&
|
||||
cache.endTime >= adjustedEndTime;
|
||||
}
|
||||
private static aggregateToHigherTimeframe(
|
||||
data: MarketData[],
|
||||
fromInterval: IntervalType,
|
||||
toInterval: IntervalType
|
||||
): MarketData[] {
|
||||
const fromMinutes = this.INTERVALS_MINUTES[fromInterval];
|
||||
const toMinutes = this.INTERVALS_MINUTES[toInterval];
|
||||
const barsPerPeriod = toMinutes / fromMinutes;
|
||||
|
||||
// Group data into higher timeframe periods
|
||||
const groupedData: { [key: number]: MarketData[] } = {};
|
||||
data.forEach(bar => {
|
||||
const periodStart = Math.floor(bar.time / (toMinutes * 60 * 1000)) * (toMinutes * 60 * 1000);
|
||||
if (!groupedData[periodStart]) {
|
||||
groupedData[periodStart] = [];
|
||||
}
|
||||
groupedData[periodStart].push(bar);
|
||||
});
|
||||
|
||||
// Aggregate each period
|
||||
return Object.entries(groupedData)
|
||||
.filter(([_, bars]) => bars.length >= barsPerPeriod * 0.7) // Require at least 70% of bars
|
||||
.map(([time, bars]) => ({
|
||||
time: parseInt(time),
|
||||
open: bars[0].open,
|
||||
high: Math.max(...bars.map(b => b.high)),
|
||||
low: Math.min(...bars.map(b => b.low)),
|
||||
close: bars[bars.length - 1].close,
|
||||
volume: bars.reduce((sum, b) => sum + b.volume, 0)
|
||||
}));
|
||||
}
|
||||
|
||||
static addMarketTime(time: number, minutesToAdd: number): number {
|
||||
let date = new Date(time);
|
||||
let currentMinutes = date.getHours() * 60 + date.getMinutes();
|
||||
let newMinutes = currentMinutes + minutesToAdd;
|
||||
|
||||
// If would exceed market close
|
||||
if (newMinutes > this.MARKET_HOURS.end) {
|
||||
// Move to next day at market open
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(this.MARKET_HOURS.start/60, 0, 0, 0);
|
||||
|
||||
// If weekend, move to Monday
|
||||
while (date.getDay() === 0 || date.getDay() === 6) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
} else {
|
||||
// Stay on same day, just update time
|
||||
date.setHours(Math.floor(newMinutes / 60), newMinutes % 60, 0, 0);
|
||||
}
|
||||
|
||||
return date.getTime();
|
||||
}
|
||||
static async getMarketData(
|
||||
symbol: string,
|
||||
config: TimeframeConfig,
|
||||
storage: MarketDataStorage
|
||||
): Promise<MarketData[]> {
|
||||
console.log('Request Config:', {
|
||||
symbol,
|
||||
interval: config.interval,
|
||||
startTime: new Date(config.startTime).toLocaleString(),
|
||||
endTime: new Date(config.endTime).toLocaleString()
|
||||
});
|
||||
try{
|
||||
// Get all cache entries for this symbol/interval
|
||||
const cacheEntries = await storage.getMarketData(symbol, config.interval);
|
||||
console.log(`Found ${cacheEntries.length} cache entries`);
|
||||
//console.log(`Checking cache for ${symbol} ${config.interval} data...`);
|
||||
const overlappingEntries = cacheEntries.filter(cache =>
|
||||
// Entry ends after request starts AND entry starts before request ends
|
||||
this.addMarketTime(cache.endTime,1) >= config.startTime && cache.startTime <= this.addMarketTime(config.endTime,1)
|
||||
);
|
||||
if (cacheEntries.length > 0) {
|
||||
// Find overlapping cache entries
|
||||
|
||||
|
||||
/*
|
||||
console.log('Found overlapping cache entries:', overlappingEntries.map(cache => ({
|
||||
startTime: new Date(cache.startTime).toLocaleString(),
|
||||
endTime: new Date(cache.endTime).toLocaleString(),
|
||||
dataPoints: cache.data.length
|
||||
})));*/
|
||||
|
||||
// Check if any single cache entry covers our request
|
||||
const coveringEntry =overlappingEntries.find(cache => this.doesCacheCover(cache, config))
|
||||
|
||||
if(coveringEntry) {
|
||||
console.log('Found complete coverage in cache:', {
|
||||
start: new Date(coveringEntry.startTime).toLocaleString(),
|
||||
end: new Date(coveringEntry.endTime).toLocaleString()
|
||||
});
|
||||
return coveringEntry.data.filter(d =>
|
||||
d.time >= config.startTime && d.time <= config.endTime
|
||||
);
|
||||
}
|
||||
console.log(`Found ${overlappingEntries.length} overlapping entries but no complete coverage`);
|
||||
}
|
||||
console.log(`Cache miss for ${symbol} ${config.interval}, fetching from API...`);
|
||||
// Check if we can build from lower timeframe data
|
||||
const intervals = Object.keys(this.INTERVALS_MINUTES) as IntervalType[];
|
||||
const lowerIntervals = intervals.filter(interval =>
|
||||
this.canDeriveFromCache(interval, config.interval)
|
||||
);
|
||||
|
||||
for (const interval of lowerIntervals) {
|
||||
const lowerCacheEntries = await storage.getMarketData(symbol, interval);
|
||||
// Find overlapping entries from lower timeframe
|
||||
const overlappingEntries = lowerCacheEntries.filter(cache =>
|
||||
cache.endTime >= config.startTime && cache.startTime <= config.endTime
|
||||
);
|
||||
|
||||
// Check if we have complete coverage from lower timeframe
|
||||
const hasFullCoverage = overlappingEntries.some(cache =>
|
||||
this.doesCacheCover(cache, {
|
||||
...config,
|
||||
interval: interval
|
||||
})
|
||||
);
|
||||
|
||||
if (hasFullCoverage) {
|
||||
// Merge overlapping lower timeframe data
|
||||
const mergedLowerData = overlappingEntries.reduce((acc, cache) =>
|
||||
[...acc, ...cache.data], [] as MarketData[]
|
||||
).sort((a, b) => a.time - b.time);
|
||||
|
||||
// We can build the higher timeframe from this data
|
||||
const aggregatedData = this.aggregateToHigherTimeframe(
|
||||
mergedLowerData,
|
||||
interval,
|
||||
config.interval
|
||||
);
|
||||
|
||||
// Cache the aggregated data
|
||||
await storage.saveMarketData(symbol, {
|
||||
data: aggregatedData,
|
||||
timestamp: Date.now(),
|
||||
interval: config.interval,
|
||||
startTime: Math.min(...aggregatedData.map(d => d.time)),
|
||||
endTime: Math.max(...aggregatedData.map(d => d.time)),
|
||||
timezone: overlappingEntries[0].timezone, // Assume timezone is consistent
|
||||
});
|
||||
|
||||
return aggregatedData.filter(d =>
|
||||
d.time >= config.startTime && d.time <= config.endTime
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we can't build from cache, fetch new data
|
||||
const {data, timezone} = await this.fetchAppropriateData(symbol, config);
|
||||
let mergedData = data;
|
||||
// If we have existing cached data, try to merge
|
||||
if (cacheEntries.length > 0) {
|
||||
|
||||
if (overlappingEntries.length > 0) {
|
||||
console.log('Attempting to merge with cached data');
|
||||
mergedData = this.mergeMarketData(
|
||||
overlappingEntries.reduce((acc, cache) => [...acc, ...cache.data], [] as MarketData[]),
|
||||
data);
|
||||
|
||||
console.log('Merged with overlapping cached data:', {
|
||||
overlappingCachePoints: overlappingEntries.reduce((sum, cache) => sum + cache.data.length, 0),
|
||||
newPoints: data.length,
|
||||
finalPoints: mergedData.length
|
||||
});
|
||||
}
|
||||
}
|
||||
const newStartTime = Math.min(...mergedData.map(d => d.time));
|
||||
const newEndTime = Math.max(...mergedData.map(d => d.time));
|
||||
|
||||
// Check if this exact time range already exists in cache
|
||||
const exactMatch = overlappingEntries.some(entry =>
|
||||
Math.abs(entry.startTime - newStartTime) < 60000 && // Within 1 minute
|
||||
Math.abs(entry.endTime - newEndTime) < 60000 && // Within 1 minute
|
||||
entry.interval === config.interval
|
||||
);
|
||||
|
||||
if (!exactMatch) {
|
||||
await storage.saveMarketData(symbol, {
|
||||
data: mergedData,
|
||||
timestamp: Date.now(),
|
||||
interval: config.interval,
|
||||
startTime: Math.min(...mergedData.map(d => d.time)),
|
||||
endTime: Math.max(...mergedData.map(d => d.time)),
|
||||
timezone
|
||||
});
|
||||
} else {
|
||||
console.log('Skipping cache save - exact time range already exists');
|
||||
}
|
||||
|
||||
return mergedData;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error in getMarketData:', error);
|
||||
throw error; // Re-throw to handle in component
|
||||
}
|
||||
|
||||
}
|
||||
private static canDeriveFromCache(sourceInterval: IntervalType, targetInterval: IntervalType): boolean {
|
||||
const sourceMinutes = this.INTERVALS_MINUTES[sourceInterval];
|
||||
const targetMinutes = this.INTERVALS_MINUTES[targetInterval];
|
||||
return sourceMinutes < targetMinutes && targetMinutes % sourceMinutes === 0;
|
||||
}
|
||||
|
||||
|
||||
private static async fetchAppropriateData(
|
||||
symbol: string,
|
||||
config: TimeframeConfig
|
||||
): Promise<{data:MarketData[], timezone: string}> {
|
||||
if (config.interval === 'daily') {
|
||||
return await this.fetchHistoricalData(symbol);
|
||||
} else {
|
||||
return await this.fetchIntraday(symbol, config.interval);
|
||||
}
|
||||
}
|
||||
private static mergeMarketData(existingData: MarketData[], newData: MarketData[]): MarketData[] {
|
||||
// Combine both arrays
|
||||
const combined = [...existingData, ...newData];
|
||||
|
||||
// Sort by time
|
||||
combined.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Remove duplicates based on timestamp
|
||||
return combined.filter((item, index, self) =>
|
||||
index === 0 || item.time !== self[index - 1].time
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
257
src/services/marketDataStorage.ts
Normal file
257
src/services/marketDataStorage.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// src/services/marketDataStorage.ts
|
||||
import { TFile, Vault } from 'obsidian';
|
||||
import { MarketData, CacheEntry, CacheMetadata, IntervalType } from './marketDataService';
|
||||
|
||||
export class MarketDataStorage {
|
||||
private vault: Vault;
|
||||
private noteFile: TFile;
|
||||
private basePath: string;
|
||||
|
||||
constructor(vault: Vault, noteFile: TFile) {
|
||||
this.vault = vault;
|
||||
this.noteFile = noteFile;
|
||||
|
||||
// Determine the appropriate base path
|
||||
this.basePath = this.determineBasePath();
|
||||
}
|
||||
private determineBasePath(): string {
|
||||
// Check if note exists and is a markdown file
|
||||
if (!this.noteFile || this.noteFile.extension !== 'md') {
|
||||
throw new Error('Invalid note file');
|
||||
}
|
||||
|
||||
// Get the parent folder path, defaulting to root if none
|
||||
const parentPath = this.noteFile.parent?.path ?? '';
|
||||
|
||||
// If at root, store in vault root
|
||||
if (!parentPath) {
|
||||
return '.market-data';
|
||||
}
|
||||
|
||||
// Store in parent folder
|
||||
return `${parentPath}/.market-data`;
|
||||
}
|
||||
getBasePath(): string {
|
||||
return this.basePath;
|
||||
}
|
||||
private async ensureDirectory(path: string) {
|
||||
try {
|
||||
if (!(await this.vault.adapter.exists(path))) {
|
||||
await this.vault.adapter.mkdir(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create directory ${path}:`, error);
|
||||
throw new Error(`Failed to create market data directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Ensure base directory exists
|
||||
await this.ensureDirectory(this.basePath);
|
||||
|
||||
// Create metadata file if it doesn't exist
|
||||
const metadataPath = `${this.basePath}/metadata.json`;
|
||||
if (!(await this.vault.adapter.exists(metadataPath))) {
|
||||
await this.vault.adapter.write(
|
||||
metadataPath,
|
||||
JSON.stringify({
|
||||
created: Date.now(),
|
||||
noteSource: this.noteFile.path
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize market data storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Helper method to get absolute path in vault
|
||||
getAbsolutePath(relativePath: string): string {
|
||||
return `${this.basePath}/${relativePath}`;
|
||||
}
|
||||
|
||||
// Method to check if storage is properly initialized
|
||||
async isInitialized(): Promise<boolean> {
|
||||
try {
|
||||
return await this.vault.adapter.exists(this.basePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Method to get storage location info
|
||||
async getStorageInfo(): Promise<{
|
||||
basePath: string;
|
||||
isInitialized: boolean;
|
||||
parentFolder: string;
|
||||
noteFile: string;
|
||||
}> {
|
||||
return {
|
||||
basePath: this.basePath,
|
||||
isInitialized: await this.isInitialized(),
|
||||
parentFolder: this.noteFile.parent?.path ?? '(root)',
|
||||
noteFile: this.noteFile.path
|
||||
};
|
||||
}
|
||||
private getSymbolPath(symbol: string) {
|
||||
return `${this.basePath}/${symbol}`;
|
||||
}
|
||||
|
||||
private getDataPath(symbol: string, interval: IntervalType) {
|
||||
return `${this.getSymbolPath(symbol)}/${interval}.json`;
|
||||
}
|
||||
|
||||
async saveMarketData(symbol: string, cacheEntry: CacheEntry): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
// Ensure symbol directory exists
|
||||
const symbolPath = this.getSymbolPath(symbol);
|
||||
await this.ensureDirectory(symbolPath);
|
||||
|
||||
// Save data file
|
||||
const dataPath = this.getDataPath(symbol, cacheEntry.interval);
|
||||
await this.vault.adapter.write(
|
||||
dataPath,
|
||||
JSON.stringify(cacheEntry, null, 2)
|
||||
);
|
||||
|
||||
// Update metadata
|
||||
await this.updateMetadata(symbol, cacheEntry);
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string, interval: IntervalType): Promise<CacheEntry[]> {
|
||||
const dataPath = this.getDataPath(symbol, interval);
|
||||
|
||||
if (await this.vault.adapter.exists(dataPath)) {
|
||||
const content = await this.vault.adapter.read(dataPath);
|
||||
const parsed = JSON.parse(content);
|
||||
// Ensure we always return an array
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async updateMetadata(symbol: string, cacheEntry: CacheEntry) {
|
||||
const metadataPath = `${this.basePath}/metadata.json`;
|
||||
let metadata: CacheMetadata = {};
|
||||
try{
|
||||
if (await this.vault.adapter.exists(metadataPath)) {
|
||||
const content = await this.vault.adapter.read(metadataPath);
|
||||
metadata = JSON.parse(content);
|
||||
}
|
||||
|
||||
// Update metadata for this symbol
|
||||
metadata[symbol] = metadata[symbol] || {};
|
||||
if (!metadata[symbol][cacheEntry.interval]) {
|
||||
metadata[symbol][cacheEntry.interval] = [];
|
||||
}
|
||||
|
||||
// Add new cache entry info
|
||||
const cacheList = metadata[symbol][cacheEntry.interval];
|
||||
if (cacheList) {
|
||||
cacheList.push({
|
||||
timestamp: cacheEntry.timestamp,
|
||||
startTime: cacheEntry.startTime,
|
||||
endTime: cacheEntry.endTime,
|
||||
interval: cacheEntry.interval,
|
||||
data: [], // Don't store actual data in metadata
|
||||
timezone: cacheEntry.timezone
|
||||
});
|
||||
|
||||
// Keep only last 5 entries
|
||||
if (cacheList.length > 5) {
|
||||
cacheList.shift();
|
||||
}
|
||||
}
|
||||
|
||||
await this.vault.adapter.write(
|
||||
metadataPath,
|
||||
JSON.stringify(metadata, null, 2)
|
||||
);
|
||||
}catch (error) {
|
||||
console.error(`Failed to update metadata for ${symbol}:`, error);
|
||||
throw new Error(`Failed to update metadata: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheStats(): Promise<{
|
||||
totalSize: number;
|
||||
symbolCount: number;
|
||||
fileCount: number;
|
||||
symbols: Record<string, {
|
||||
intervals: IntervalType[];
|
||||
lastUpdated: number;
|
||||
}>;
|
||||
}> {
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
const symbols: Record<string, {
|
||||
intervals: IntervalType[];
|
||||
lastUpdated: number;
|
||||
}> = {};
|
||||
try{
|
||||
if (await this.vault.adapter.exists(this.basePath)) {
|
||||
const files = await this.vault.adapter.list(this.basePath);
|
||||
|
||||
for (const file of files.files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const stat = await this.vault.adapter.stat(file);
|
||||
if (!stat) continue; // Skip if stat is null
|
||||
totalSize += stat.size;
|
||||
fileCount++;
|
||||
|
||||
// Parse symbol and interval info
|
||||
const pathParts = file.split('/');
|
||||
if (pathParts.length >= 2) {
|
||||
const symbol = pathParts[pathParts.length - 2];
|
||||
const interval = pathParts[pathParts.length - 1].replace('.json', '') as IntervalType;
|
||||
|
||||
if (!symbols[symbol]) {
|
||||
symbols[symbol] = {
|
||||
intervals: [],
|
||||
lastUpdated: stat.mtime
|
||||
};
|
||||
}
|
||||
symbols[symbol].intervals.push(interval);
|
||||
symbols[symbol].lastUpdated = Math.max(symbols[symbol].lastUpdated, stat.mtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalSize,
|
||||
symbolCount: Object.keys(symbols).length,
|
||||
fileCount,
|
||||
symbols
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache stats:', error);
|
||||
return {
|
||||
totalSize: 0,
|
||||
symbolCount: 0,
|
||||
fileCount: 0,
|
||||
symbols: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(maxAge: number = 7 * 24 * 60 * 60 * 1000) { // Default 7 days
|
||||
const stats = await this.getCacheStats();
|
||||
const now = Date.now();
|
||||
|
||||
for (const [symbol, info] of Object.entries(stats.symbols)) {
|
||||
if (now - info.lastUpdated > maxAge) {
|
||||
// Remove old symbol data
|
||||
const symbolPath = this.getSymbolPath(symbol);
|
||||
if (await this.vault.adapter.exists(symbolPath)) {
|
||||
await this.vault.adapter.rmdir(symbolPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/types/babel-standalone.d.ts
vendored
Normal file
31
src/types/babel-standalone.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// src/types/babel-standalone.d.ts
|
||||
declare module '@babel/standalone' {
|
||||
export interface TransformOptions {
|
||||
filename?: string;
|
||||
presets?: (string | [string, object])[]; //Also allow tuple
|
||||
plugins?: any[];
|
||||
configFile?: boolean | string;
|
||||
babelrc?: boolean;
|
||||
sourceType?: 'script' | 'module' | 'unambiguous';
|
||||
sourceMaps?: boolean;
|
||||
sourceFileName?: string;
|
||||
code?: boolean;
|
||||
ast?: boolean;
|
||||
minified?: boolean;
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
code: string;
|
||||
map?: any;
|
||||
ast?: any;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export function transform(
|
||||
code: string,
|
||||
options?: TransformOptions
|
||||
): TransformResult;
|
||||
|
||||
export const availablePresets: { [key: string]: any };
|
||||
export const availablePlugins: { [key: string]: any };
|
||||
}
|
||||
7
src/types/global.d.ts
vendored
Normal file
7
src/types/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
React: typeof import('react');
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
335
styles.css
Normal file
335
styles.css
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/* styles.css */
|
||||
|
||||
/* Spacing utilities */
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
|
||||
/* Layout utilities */
|
||||
.flex { display: flex; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
|
||||
.w-full { width: 100%; }
|
||||
.space-y-2 > * + * { margin-top: 0.5rem; }
|
||||
|
||||
/* Colors - using Obsidian CSS variables */
|
||||
.text-green-500 { color: var(--color-green); }
|
||||
.text-red-500 { color: var(--color-red); }
|
||||
.text-blue-500 { color: var(--interactive-accent); }
|
||||
|
||||
/* Font utilities */
|
||||
.font-medium { font-weight: 500; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-lg { font-size: 1.125rem; }
|
||||
|
||||
|
||||
/* Border utilities */
|
||||
.rounded { border-radius: 4px; }
|
||||
.border { border: 1px solid var(--background-modifier-border); }
|
||||
.border-slate-700 { border-color: var(--background-modifier-border-focus); }
|
||||
|
||||
/* Component styles */
|
||||
.react-plugin-card {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
margin-bottom: 1rem; }
|
||||
|
||||
.react-plugin-card-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border); }
|
||||
|
||||
.react-plugin-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal); }
|
||||
|
||||
.react-plugin-card-content { padding: 1rem; }
|
||||
|
||||
.react-plugin-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem; }
|
||||
|
||||
.react-plugin-tabs-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 0.5rem; }
|
||||
|
||||
.react-plugin-tab-trigger {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px; }
|
||||
|
||||
.react-plugin-tab-trigger[data-state="active"] {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal); }
|
||||
|
||||
/* Chart container styles */
|
||||
.chart-container {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden; /* Add this to prevent any overflow */
|
||||
min-height: 400px;
|
||||
background: var(--background-secondary); }
|
||||
|
||||
.tv-lightweight-charts {
|
||||
height: 500px !important; /* Force height if needed
|
||||
overflow: visible; /* Show x-axis labels if they overflow */
|
||||
}
|
||||
|
||||
|
||||
/* List styles */
|
||||
.list-content {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
list-style-type: none; }
|
||||
|
||||
.list-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-normal); }
|
||||
|
||||
.heading {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 600; }
|
||||
|
||||
/* Prevent conflicts with Obsidian's styles */
|
||||
.react-plugin-card button { box-shadow: none; }
|
||||
|
||||
.react-plugin-card button:hover { box-shadow: none; }
|
||||
.react-component-error {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.react-component-error pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
/* Dark mode adjustments are handled by Tailwind classes */
|
||||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
|
||||
/* Chart container styles */
|
||||
.recharts-wrapper {
|
||||
min-height: 400px !important;
|
||||
min-width: 300px !important;
|
||||
}
|
||||
|
||||
/* Ensure proper sizing for ResponsiveContainer */
|
||||
.recharts-responsive-container {
|
||||
min-height: 400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments for charts */
|
||||
.theme-dark .recharts-cartesian-grid line {
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.theme-dark .recharts-text {
|
||||
fill: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.theme-dark .recharts-tooltip-wrapper {
|
||||
background-color: #1a1b1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
|
||||
/*Tail Wind stuff */
|
||||
/* styles.css */
|
||||
|
||||
/* Layout */
|
||||
.w-full { width: 100%; }
|
||||
.w-3\/4 { width: 75%; }
|
||||
.w-1\/2 { width: 50%; }
|
||||
.h-64 { height: 16rem; }
|
||||
.h-32 { height: 8rem; }
|
||||
.h-16 { height: 4rem; }
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
|
||||
/* Spacing */
|
||||
.p-4 { padding: 1rem; }
|
||||
.mt-16 { margin-top: 4rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.ml-8 { margin-left: 2rem; }
|
||||
.top-2 { top: 0.5rem; }
|
||||
.left-2 { left: 0.5rem; }
|
||||
|
||||
/* Block colors - with dark mode support */
|
||||
.bg-red-200 {
|
||||
background-color: rgba(254, 202, 202, 0.8);
|
||||
}
|
||||
.bg-orange-200 {
|
||||
background-color: rgba(254, 215, 170, 0.8);
|
||||
}
|
||||
.bg-yellow-200 {
|
||||
background-color: rgba(254, 240, 138, 0.8);
|
||||
}
|
||||
.bg-slate-100 {
|
||||
background-color: rgba(241, 245, 249, 0.8);
|
||||
}
|
||||
|
||||
.theme-dark .bg-red-200 {
|
||||
background-color: rgba(254, 202, 202, 0.2);
|
||||
}
|
||||
.theme-dark .bg-orange-200 {
|
||||
background-color: rgba(254, 215, 170, 0.2);
|
||||
}
|
||||
.theme-dark .bg-yellow-200 {
|
||||
background-color: rgba(254, 240, 138, 0.2);
|
||||
}
|
||||
.theme-dark .bg-slate-100 {
|
||||
background-color: rgba(241, 245, 249, 0.1);
|
||||
}
|
||||
|
||||
/* Border colors */
|
||||
.border-red-500 { border-color: rgb(239, 68, 68); }
|
||||
.border-orange-500 { border-color: rgb(249, 115, 22); }
|
||||
.border-yellow-500 { border-color: rgb(234, 179, 8); }
|
||||
|
||||
/* Border styles */
|
||||
.border-2 { border-width: 2px; }
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
|
||||
/* Grid */
|
||||
.grid { display: grid; }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.gap-4 { gap: 1rem; }
|
||||
|
||||
/* Typography */
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.font-medium { font-weight: 500; }
|
||||
|
||||
/* Flex */
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
|
||||
/* Spacing utilities */
|
||||
.space-y-4 > * + * { margin-top: 1rem; }
|
||||
.space-y-2 > * + * { margin-top: 0.5rem; }
|
||||
|
||||
/* Container styles - ensures proper rendering in Obsidian */
|
||||
.react-component-container {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--font-interface);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.theme-dark .react-component-container {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Base text positioning */
|
||||
.nested-block-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
z-index: 10; /* Ensure text stays above blocks */
|
||||
background-color: inherit; /* Match parent block background */
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Ensure blocks stack properly */
|
||||
.nested-block {
|
||||
position: relative;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
z-index: 1; /* Base z-index */
|
||||
}
|
||||
|
||||
/* Specific z-indices for each block type */
|
||||
.nested-block.primary {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nested-block.secondary {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nested-block.tertiary {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Adjust backgrounds to be slightly transparent */
|
||||
.bg-red-200 {
|
||||
background-color: rgba(254, 202, 202, 0.95);
|
||||
}
|
||||
.bg-orange-200 {
|
||||
background-color: rgba(254, 215, 170, 0.95);
|
||||
}
|
||||
.bg-yellow-200 {
|
||||
background-color: rgba(254, 240, 138, 0.95);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.theme-dark .bg-red-200 {
|
||||
background-color: rgba(254, 202, 202, 0.15);
|
||||
}
|
||||
.theme-dark .bg-orange-200 {
|
||||
background-color: rgba(254, 215, 170, 0.15);
|
||||
}
|
||||
.theme-dark .bg-yellow-200 {
|
||||
background-color: rgba(254, 240, 138, 0.15);
|
||||
}
|
||||
|
||||
/* Ensure proper spacing */
|
||||
.nested-block + .nested-block {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "React.createElement", // Add this
|
||||
"jsxFragmentFactory": "React.Fragment", // Add this
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7",
|
||||
"ES2020"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./src/types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue