-
Notifications
You must be signed in to change notification settings - Fork 237
docs: Document the model visualization contract (service-module + platform layers) #2568
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
base: main
Are you sure you want to change the base?
Changes from all commits
af3d3a7
3e08998
0661b89
a26e514
3a707e9
f619d1a
9f60c84
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,161 @@ | ||
| [#_platform_visualization] | ||
| = Visualization | ||
| :description: How a model's custom visualization UI is embedded and driven once deployed to Timefold Platform. | ||
| :doctype: book | ||
| :sectnums: | ||
| :icons: font | ||
|
|
||
| A model's solution can have a custom visualization UI, rendered inside Timefold Platform as an iframe, instead of consumers only seeing the raw solution data or the generic score analysis view. | ||
|
|
||
| include::_preview-note.adoc[] | ||
|
|
||
| [CAUTION] | ||
| ==== | ||
| This page documents how visualization works today, based directly on the current platform implementation. | ||
| The contract described here, including iframe sizing, refresh behavior, asset paths, and page-announcement metadata, is still evolving and may change as the platform's visualization support matures, possibly without a smooth migration path. | ||
| ==== | ||
|
|
||
| [#_building_the_ui] | ||
| == Building the UI | ||
|
|
||
| The UI itself is the same set of static files described in xref:running-timefold-solver/service/visualization.adoc[], placed under `src/main/resources/META-INF/resources`. | ||
| Once deployed, the platform repackages and serves these files under a `ui/` prefix, so the entry point the platform loads must be exactly `ui/index.html`. | ||
|
|
||
| Asset references in `ui/index.html` must be relative (for example `./assets/main.js`), not root-relative (`/assets/main.js`): the UI isn't served from the domain root, so an absolute path resolves against the platform's own root instead of the `ui/` prefix. | ||
|
|
||
| [#_iframe_embedding] | ||
| == How the platform embeds the UI | ||
|
|
||
| The platform renders `ui/index.html` inside an iframe that it resizes to your content's reported height, as described in <<_reporting_your_height>>. | ||
| The iframe's `src` always carries `onPlatform=1`, plus a `page` parameter when the model declares visualization pages, as described in <<_announcing_visualization_pages>>. | ||
|
|
||
| The iframe has scrolling disabled. | ||
| If your UI never sends a `resize` message, the iframe keeps the browser's default height instead of growing to fit your content, and anything beyond that height is cut off. | ||
| Either report your height, or put your content in a scrollable container of your own. | ||
|
|
||
| [#_calling_your_api_from_the_iframe] | ||
| == Calling your model's API from inside the iframe | ||
|
|
||
| After the iframe loads, the platform `postMessage`s an `init` message to it, carrying `tenantId`, `runId`, `apiUrl`, `apiKey`, and the user's preferred `unitSystem` (`metric` or `imperial`): | ||
|
|
||
| [source,json] | ||
| ---- | ||
| { | ||
| "source": "timefold-visualization", | ||
| "type": "init", | ||
| "data": { | ||
| "tenantId": "...", | ||
| "runId": "...", | ||
| "apiUrl": "...", | ||
| "apiKey": "...", | ||
| "unitSystem": "metric" | ||
| } | ||
| } | ||
| ---- | ||
|
|
||
| Listen for it, and reply with an `init-response` so the platform knows the UI is handling the handshake: | ||
|
|
||
| [source,js] | ||
| ---- | ||
| window.addEventListener("message", (event) => { | ||
| if (event.origin !== window.location.origin) return; | ||
| if (event.data?.source !== "timefold-visualization") return; | ||
| if (event.data.type !== "init") return; | ||
|
|
||
| const { tenantId, runId, apiUrl, apiKey, unitSystem } = event.data.data; | ||
| // ...store these for your API calls... | ||
|
|
||
| event.source.postMessage( | ||
| { source: "timefold-visualization", type: "init-response" }, | ||
| event.origin, | ||
| ); | ||
| }); | ||
| ---- | ||
|
|
||
| Reply synchronously from your message handler. | ||
| The platform waits only briefly before falling back to the older query-parameter contract: it reloads the iframe with the same `init` data appended to its `src` URL as query parameters, and makes the iframe scrollable. | ||
| Support that fallback too if you want your UI to keep working against platform versions that don't yet send `init`. | ||
|
|
||
| When the user changes their unit preference later, the platform sends a separate message with the new value: | ||
|
|
||
| [source,json] | ||
| ---- | ||
| { | ||
| "source": "timefold-visualization", | ||
| "type": "unit-system", | ||
| "data": { "unitSystem": "imperial" } | ||
| } | ||
| ---- | ||
|
|
||
| Strip any trailing slash from `apiUrl` and prepend it to your own API calls, so they're routed correctly regardless of where the platform proxies from. | ||
| Append the path your model's own REST API is served under, which is the same path you'd hit locally, as described in xref:running-timefold-solver/service/visualization.adoc#_calling_your_api[Calling your REST API from the UI]. | ||
| Only the base changes between running locally and running embedded in the platform. | ||
|
|
||
| [#_reporting_your_height] | ||
| == Reporting your height | ||
|
|
||
| The platform sizes the iframe from what your UI reports, not from a fixed viewport. Post a `resize` message whenever your content's height changes: | ||
|
|
||
| [source,js] | ||
| ---- | ||
| window.parent.postMessage( | ||
| { source: "timefold-visualization", type: "resize", height: document.body.scrollHeight }, | ||
| window.location.origin, | ||
| ); | ||
| ---- | ||
|
|
||
| The reported height is clamped between 200px and 50000px; above that ceiling the platform makes the iframe scrollable instead of growing it further. | ||
|
|
||
| [#_refreshing_while_solving] | ||
| == Refreshing while solving | ||
|
|
||
| The platform doesn't push updates into the iframe or refresh it automatically. | ||
| Your UI needs to poll its own status or solution endpoint on an interval, and stop polling once the dataset's status leaves the active or solving set. | ||
|
|
||
| [#_error_reporting] | ||
| == Error reporting | ||
|
|
||
| The platform automatically injects a small error-forwarding script as the first script of the served HTML. | ||
| It reports the following to the platform: | ||
|
|
||
| - Uncaught JavaScript errors and unhandled promise rejections. | ||
| - Scripts, stylesheets, and images that fail to load. | ||
| - Output written to `console.error`. | ||
| - A page that still renders nothing visible 5 seconds after it loads. | ||
|
|
||
| If `ui/index.html` itself can't be served, the iframe shows an error page instead, and a missing entry point (HTTP 404) gets its own message. | ||
|
|
||
| These alerts are shown only to model maintainers, not to the users viewing a dataset. | ||
| Treat them as a debugging aid, and show users your own error state when something goes wrong. | ||
|
|
||
| [#_announcing_visualization_pages] | ||
| == Announcing visualization pages | ||
|
|
||
| A model can offer multiple types of visualization, for example a map, a table, and a Gantt chart. | ||
| Declaring these as pages is optional. | ||
|
|
||
| Without any declared pages, the platform adds a single *Visualization* entry to the dataset's sidebar, which loads `ui/index.html` without a `page` parameter. | ||
|
|
||
| With declared pages, the platform adds one sidebar entry per page instead, in the declared order, using each page's label and icon. | ||
| Every entry loads the same `ui/index.html`, with the page's key passed as the `page` query parameter, for example `ui/index.html?onPlatform=1&page=map`. | ||
| Your UI reads `page` from `window.location.search` and renders the matching view. | ||
|
|
||
| Visualization entries only appear for datasets that have a solution, and they're not available on mobile. | ||
|
|
||
| Declare pages through build-time configuration: | ||
|
|
||
| [source,properties,options="nowrap"] | ||
| ---- | ||
| timefold.model.visualization.pages[0].key=map | ||
| timefold.model.visualization.pages[0].icon=TbMap | ||
| timefold.model.visualization.pages[0].label=Map | ||
| timefold.model.visualization.pages[1].key=gantt | ||
| timefold.model.visualization.pages[1].icon=TbChartGantt | ||
| timefold.model.visualization.pages[1].label=Gantt chart | ||
| ---- | ||
|
|
||
| Each declared page has three required fields; omitting any of them fails the build. | ||
|
Contributor
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. How does this help with routing? Are pages a new concept introduced with the specific front-end components? Is the KEY also a tab in the UI?
Contributor
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. Is this required now? Or if this is not supplied, does it use "visualization" for whatever is in the UI folder? |
||
|
|
||
| - `key`: a stable identifier for the page, used in the sidebar entry's URL and as the `page` query parameter. | ||
| - `icon`: an icon name from https://tabler.io/icons[Tabler Icons], for example `TbMap` or `IconMap`. An unknown name falls back to a generic icon. | ||
| - `label`: the human-readable name shown to users. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| [#_visualization] | ||
| = Visualization | ||
| :page-aliases: service/visualization.adoc | ||
| :description: How to build a custom visualization UI for your model and serve it locally. | ||
| :doctype: book | ||
| :sectnums: | ||
| :icons: font | ||
|
|
||
| A model's solution is often easier to understand as a rendered UI than as raw JSON. | ||
| This page describes how to build a custom visualization UI for your model and serve it from the service module while running locally. | ||
|
|
||
| See xref:deploying-to-platform/visualization.adoc[] for how this same UI is embedded once your model is deployed to Timefold Platform. | ||
|
|
||
| [#_serving_a_ui_locally] | ||
| == Serving a UI locally | ||
|
|
||
| Any static file placed under `src/main/resources/META-INF/resources` is served by Quarkus at the site root. | ||
| For example, an `index.html` and `app.js` placed there are served at `http://localhost:8080/index.html` and `http://localhost:8080/app.js`. | ||
|
|
||
| This static-resource handling is independent of your REST API path configuration: the UI files and the API endpoints are served from the same Quarkus instance, but the UI does not sit under whatever `@Path` your `ModelRest` interface declares. | ||
|
|
||
| [#_ui_support_property] | ||
| == Enabling the UI in the model descriptor archive | ||
|
|
||
| Whether the archive generated around your xref:deploying-to-platform/guide.adoc#_what_happens_on_deploy[model descriptor] (`model-descriptor.zip`) bundles a UI is controlled by the build-time `timefold.model.ui-support` property, which accepts one of two values: | ||
|
|
||
| - `NONE`: no UI is bundled. | ||
| - `APP_JS`: the files under `src/main/resources/META-INF/resources` are bundled as the model's UI. | ||
|
|
||
| If you don't set this property explicitly, it's auto-detected: if `src/main/resources/META-INF/resources` exists and contains at least one file, `APP_JS` is used; otherwise, `NONE` is used. | ||
|
|
||
| [#_tips] | ||
| == Tips | ||
|
|
||
| [#_relative_asset_paths] | ||
| === Use relative asset paths | ||
|
|
||
| When deployed to Timefold Platform, the same `META-INF/resources` files are repackaged and served under a `ui/` prefix instead of the site root (see xref:deploying-to-platform/visualization.adoc[]). | ||
| A root-absolute reference like `<script src="/app.js">` breaks once moved under that prefix; a relative one like `<script src="./app.js">` still resolves correctly. | ||
| Use relative asset paths in your `index.html` for this reason. | ||
|
|
||
| [#_cors] | ||
| === Enable CORS for external dev servers | ||
|
|
||
| The service module doesn't configure CORS for you. | ||
| To run your UI on a separate dev server (Vite, webpack, ...), add: `quarkus.http.cors=true`. | ||
|
|
||
| See the https://quarkus.io/guides/http-reference#cors-filter[Quarkus CORS guide] for how to restrict allowed origins, methods, or headers. | ||
|
|
||
| [#_calling_your_api] | ||
| == Calling your REST API from the UI | ||
|
|
||
| Your UI calls your model's REST API the same way any other client would. | ||
|
|
||
| Open the Swagger UI at `http://localhost:8080/q/swagger-ui/`, introduced in xref:quickstart/service/getting-started.adoc[Getting started: building a service], to check the exact path, rather than assuming a fixed prefix. | ||
|
|
||
| See xref:deploying-to-platform/visualization.adoc[] for how to target your API from inside the platform's iframe. |
Uh oh!
There was an error while loading. Please reload this page.