-
Notifications
You must be signed in to change notification settings - Fork 129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
embedded api #1637
embedded api #1637
Changes from all commits
040628e
738f462
44fbdaa
58887fa
e7bf5e5
da765ba
8dc4fa1
f547f98
e8b05da
032c127
dcb4aeb
527b5ec
3b1d315
845077c
c11a7a3
e6d24ca
69d13b4
607da48
8cb93eb
60bae2f
e41893e
8a3be6c
15d6041
aae83bd
4bcea11
9b0a3c2
195d4a4
362407f
518e67b
a7d962a
811df03
a4cc2fc
86b77b8
773a45a
b57cccc
e3ad0ba
3451619
6fba2ae
afc00a8
9a966f6
90e3e2c
6e24c25
0cc7a02
99508da
9f98801
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import {FileAttachment} from "npm:@observablehq/stdlib"; | ||
import * as Plot from "npm:@observablehq/plot"; | ||
|
||
export async function Chart() { | ||
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true}); | ||
return Plot.plot({ | ||
y: {grid: true}, | ||
color: {scheme: "burd"}, | ||
marks: [Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), Plot.ruleY([0])] | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,130 @@ | ||||||
# Embedded analytics <a href="https://github.com/observablehq/framework/pull/1637" class="observablehq-version-badge" data-version="prerelease" title="Added in #1637"></a> | ||||||
|
||||||
In addition to generating full-page apps, Framework can generate modules to embed analytics — such as individual charts or tables, or coordinated interactive views — in external applications. Embedded modules take full advantage of Framework’s polyglot, baked data architecture for instant page loads. | ||||||
|
||||||
Embedded modules are vanilla JavaScript, and behave identically when embedded in an external application as on a Framework page. As always, you can load data from a [data loader](./data-loaders) using [`FileAttachment`](./files), and you can [import](./imports) [self-hosted](./imports#self-hosting-of-npm-imports) local modules and libraries from npm; file and import resolutions are baked into the generated code at build time so that imported modules “just work”. | ||||||
|
||||||
Embedded modules are often written as component functions that return DOM elements. These functions can take options (or “props”), and typically load their own data. For example, below is a simple `chart.js` module that exports a `Chart` function that renders a scatterplot of global surface temperature data. | ||||||
|
||||||
```js run=false | ||||||
import {FileAttachment} from "npm:@observablehq/stdlib"; | ||||||
import * as Plot from "npm:@observablehq/plot"; | ||||||
|
||||||
export async function Chart() { | ||||||
const gistemp = await FileAttachment("./lib/gistemp.csv").csv({typed: true}); | ||||||
return Plot.plot({ | ||||||
y: {grid: true}, | ||||||
color: {scheme: "burd"}, | ||||||
marks: [ | ||||||
Plot.dot(gistemp, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), | ||||||
Plot.ruleY([0]) | ||||||
] | ||||||
}); | ||||||
} | ||||||
``` | ||||||
|
||||||
<div class="note"> | ||||||
|
||||||
When Framework builds your app, any transitive static imports are preloaded automatically when the embedded module is imported. This ensures optimal performance by avoiding long request chains. | ||||||
|
||||||
</div> | ||||||
|
||||||
## Embedding modules | ||||||
|
||||||
To allow a module to be embedded in an external application, declare the module’s path in your [config file](./config) using the [**dynamicPaths** option](./config#dynamic-paths). For example, to embed a single component named `chart.js`: | ||||||
|
||||||
```js run=false | ||||||
export default { | ||||||
dynamicPaths: [ | ||||||
"/chart.js" | ||||||
] | ||||||
}; | ||||||
``` | ||||||
|
||||||
Or for [parameterized routes](./params), name the component `product-[id]/chart.js`, then load a list of product identifiers from a database with a SQL query: | ||||||
|
||||||
```js run=false | ||||||
import postgres from "postgres"; | ||||||
|
||||||
const sql = postgres(); // Note: uses psql environment variables | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment exists in multiple places. (I’ve been copying it every time I use Postgres.js.) I’m not sure we need it. The link from the text is probably enough, but I did think it was useful to mention that it depends on environment variables. |
||||||
|
||||||
export default { | ||||||
async *dynamicPaths() { | ||||||
for await (const {id} of sql`SELECT id FROM products`.cursor()) { | ||||||
yield `/product-${id}/chart.js`; | ||||||
} | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
An embedded component can be imported into a vanilla web application like so: | ||||||
|
||||||
```html run=false | ||||||
<script type="module"> | ||||||
|
||||||
import {Chart} from "https://my-workspace.observablehq.cloud/my-app/chart.js"; | ||||||
|
||||||
document.body.append(await Chart()); | ||||||
|
||||||
</script> | ||||||
``` | ||||||
|
||||||
<div class="note"> | ||||||
|
||||||
The code above assumes the Framework app is called “my-app” and that it’s deployed to Observable Cloud in the workspace named “my-workspace”. | ||||||
|
||||||
</div> | ||||||
|
||||||
<div class="note"> | ||||||
|
||||||
If the external (host) application is on a different origin than the Framework app — for example, if the host application is on example.com and the Framework app is on app.example.com — then you will need to [enable CORS](https://enable-cors.org/) on app.example.com or use a proxy to forward requests from example.com to app.example.com for same-origin serving. | ||||||
|
||||||
</div> | ||||||
|
||||||
In React, you can do something similar using [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) and [`useEffect`](https://react.dev/reference/react/useEffect) and [`useRef`](https://react.dev/reference/react/useRef) hooks: | ||||||
|
||||||
```jsx run=false | ||||||
import {useEffect, useRef} from "react"; | ||||||
|
||||||
export function EmbedChart() { | ||||||
const ref = useRef(null); | ||||||
|
||||||
useEffect(() => { | ||||||
let parent = ref.current, child; | ||||||
import("https://my-workspace.observablehq.cloud/my-app/chart.js") | ||||||
.then(({Chart}) => Chart()) | ||||||
.then((chart) => parent?.append((child = chart))); | ||||||
return () => ((parent = null), child?.remove()); | ||||||
}, []); | ||||||
|
||||||
return <div ref={ref} />; | ||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had to test this and log every line of it! it works :) |
||||||
``` | ||||||
|
||||||
<div class="tip"> | ||||||
|
||||||
Since both dynamic import and the imported component are async, the code above is careful to clean up the effect and avoid race conditions. | ||||||
|
||||||
</div> | ||||||
|
||||||
<div class="tip"> | ||||||
|
||||||
You can alternatively embed Framework pages using [iframe embeds](https://observablehq.observablehq.cloud/framework-example-responsive-iframe/). | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Speaking of which, an example of a responsive chart (that reruns to adapt to the container's width) might be welcome here, or separately in a full-blown example. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. That should just be the |
||||||
|
||||||
</div> | ||||||
|
||||||
## Developing modules | ||||||
|
||||||
To develop your component, you can import it into a Framework page like normal, giving you instant reactivity as you make changes to the component or its data. | ||||||
|
||||||
```js echo | ||||||
import {Chart} from "./chart.js"; | ||||||
``` | ||||||
|
||||||
To instantiate the imported component, simply call the function: | ||||||
|
||||||
```js echo | ||||||
Chart() | ||||||
``` | ||||||
|
||||||
A Framework page can serve as live documentation for your component: you can describe and demonstrate all the states and options for your component, and review the behavior visually. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit too allusive. It might be worth developing an example of how to use
observable.params.id
withinchart.js
so that it corresponds to the requested product and displays data from the corresponding (parametrized) data loader. The example can live as a stand-alone example app, and we'd just add a link here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I didn’t want to duplicate the documentation of parameterized routes here, but I also wanted to allude to it, since it feels like a natural question (how would I show multiple views?) with perhaps a surprising answer (the views bake-in their data rather than expecting it to be passed in as a prop from the host app).