Add private PHP scientific workbench and native map editor
This commit is contained in:
1 parent
be871f1dd9
commit
628824407f
79 files changed
+5493
-1
No files matched your search
@@ -0,0 +1,66 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
.fuse*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
log/
|
||||
private/
|
||||
public/
|
||||
config/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
@@ -0,0 +1,15 @@
|
||||
# ExoPlaSim web application
|
||||
|
||||
This directory is an application fork of Udo's maintained PHP web-app-starter. Project behavior belongs here. Do not put ExoPlaSim changes into the generic starter repository.
|
||||
|
||||
Use the existing front controller, URL routing, component(), component_call(), safe(), and asafe(). Theme shell fragments belong in components/theme/shell.php. Do not add a second rendering framework or helper layer.
|
||||
|
||||
Keep model execution and result extraction in the background worker. PHP requests read the queue and prepared artifacts. Do not execute a simulator, compiler, or Python process in a request.
|
||||
|
||||
This is a single-user application. Do not add accounts, sign-in, or logout. This is a private dev toy. Do not add CSRF, sessions, network gates, TLS requirements, or application-specific nginx configuration. Keep SQLite, runs, and output outside the served source tree. Do not expose raw filesystem paths.
|
||||
|
||||
Use config/web.json in the project root as the shared configuration source. Use the generated parameter catalog for scientific defaults. Read docs/work/web-ui.md for the current contracts and acceptance criteria.
|
||||
|
||||
Keep UI copy limited to controls, data, units, and actionable status. Do not add demo records, marketing content, planning explanations, or inactive controls.
|
||||
|
||||
Lint PHP, check JavaScript syntax, run the focused PHP/Python tests, and verify the real browser workflow before reporting completion. Use finite timeouts. Do not add hosted automation.
|
||||
@@ -0,0 +1,7 @@
|
||||
# ExoPlaSim workbench
|
||||
|
||||
This directory contains the PHP application and browser assets. It uses the maintained PHP starter shell, escaping helpers, and component loader.
|
||||
|
||||
See [the workbench guide](../docs/web-ui.md) for installation, input maps, APIs, and tests. Current open work is in [the goal tree](../docs/work/web-ui.md).
|
||||
|
||||
Run this private dev app through ordinary PHP hosting. It needs no application-specific nginx configuration, TLS setup, accounts, sessions, or security gates. Application operations use ordinary query parameters, not URL paths.
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
return [
|
||||
'render' => function($prop) {
|
||||
$bootstrap = $prop['bootstrap'] ?? [];
|
||||
ob_start(); ?>
|
||||
<main class="exo-workbench" id="workbench" aria-label="ExoPlaSim workbench">
|
||||
<header class="exo-toolbar">
|
||||
<a class="exo-brand" href="<?= asafe(URL::Link('')) ?>"><span class="exo-mark" aria-hidden="true">E</span><span>ExoPlaSim</span></a>
|
||||
<div class="exo-toolbar-title"><strong id="active-experiment-title">New experiment</strong><span id="save-state" class="save-state" role="status">Unsaved</span></div>
|
||||
<div class="exo-toolbar-actions">
|
||||
<button class="button quiet" type="button" data-action="import">Import</button>
|
||||
<button class="button quiet" type="button" data-action="export" disabled>Export</button>
|
||||
<button class="button" type="button" data-action="save">Save</button>
|
||||
<button class="button primary" type="button" data-action="save-run">Save & run</button>
|
||||
<input id="import-file" type="file" accept="application/json,.json" hidden>
|
||||
</div>
|
||||
</header>
|
||||
<div class="exo-layout">
|
||||
<aside class="experiment-pane" aria-label="Experiments">
|
||||
<div class="pane-heading"><span>EXPERIMENTS</span><button type="button" class="icon-button" data-action="new" aria-label="New experiment">+</button></div>
|
||||
<label class="search-box"><span aria-hidden="true">⌕</span><input id="experiment-search" type="search" placeholder="Find experiment" aria-label="Find experiment"></label>
|
||||
<ul id="experiment-list" class="experiment-list" aria-live="polite"></ul>
|
||||
<button class="text-button" type="button" data-action="clone" disabled>Duplicate selected</button>
|
||||
<button class="text-button danger-text" type="button" data-action="delete" disabled>Delete selected</button>
|
||||
<button class="text-button" type="button" data-action="generate">New random planet</button>
|
||||
</aside>
|
||||
<section class="results-pane" aria-label="Model results">
|
||||
<div class="results-toolbar"><div class="section-label"><span class="live-dot"></span><span id="result-caption">RESULTS</span></div><div id="visualization-downloads" class="download-actions"></div></div>
|
||||
<div id="visualization" class="visualization" aria-live="polite"><div class="empty-results">Select a completed run to inspect its spatial output.</div></div>
|
||||
<div class="job-pane">
|
||||
<div class="pane-heading"><span>RUN QUEUE</span><button class="text-button" type="button" data-action="refresh">Refresh</button></div>
|
||||
<div id="job-list" class="job-list" aria-live="polite"></div>
|
||||
<div class="log-head"><span id="log-title">Run log</span><span id="log-cursor"></span><div><button class="text-button" type="button" data-action="cancel" disabled>Cancel</button><button class="text-button" type="button" data-action="retry" disabled>Retry</button></div></div>
|
||||
<output id="job-error" class="job-error" role="status" hidden></output>
|
||||
<pre id="job-log" class="job-log" tabindex="0" aria-label="Selected run log">Select a run to view its log.</pre>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="parameter-pane" aria-label="Experiment parameters">
|
||||
<div class="pane-heading"><span>CONFIGURATION</span><button class="text-button" type="button" data-action="reset">Reset</button></div>
|
||||
<label class="field-label" for="experiment-name">Experiment name</label><input id="experiment-name" class="control" type="text" maxlength="120" autocomplete="off">
|
||||
<div class="constructor-grid">
|
||||
<label><span>Resolution</span><select id="model-resolution" class="control"></select></label>
|
||||
<label><span>Layers</span><input id="model-layers" class="control" type="number" min="1" step="1"></label>
|
||||
<label><span>CPUs</span><input id="model-ncpus" class="control" type="number" min="1" step="1"></label>
|
||||
<label><span>Cycles</span><input id="run-cycles" class="control" type="number" min="1" step="1"></label>
|
||||
<label class="wide"><span>Steps per cycle</span><input id="run-steps" class="control" type="number" min="1" step="1" placeholder="Full model year"><small>Leave blank for a full model year. A custom step count is not a full year.</small></label>
|
||||
</div>
|
||||
<div class="surface-map-controls"><button class="button" type="button" data-action="edit-maps">Edit maps</button><button class="text-button" type="button" data-action="clear-maps" disabled>Use original maps</button><output id="surface-map-status"></output></div>
|
||||
<label class="field-label catalog-label" for="parameter-search">MODEL PARAMETERS <span id="parameter-count"></span></label>
|
||||
<input id="parameter-search" class="control" type="search" placeholder="Search name, group, or description">
|
||||
<div id="parameter-fields" class="parameter-fields" aria-live="polite"></div>
|
||||
<div id="editor-errors" class="editor-errors" role="alert"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<dialog id="generator-dialog" class="generator-dialog">
|
||||
<form method="dialog"><button class="icon-button dialog-close" aria-label="Close">×</button></form>
|
||||
<form id="generator-form">
|
||||
<label>Seed <input class="control" name="seed" type="number" required></label>
|
||||
<button class="text-button" type="button" data-action="regenerate">New seed</button>
|
||||
<label>Land fraction <input class="control" name="land_fraction" type="number" required></label>
|
||||
<label>Relief (m) <input class="control" name="relief" type="number" required></label>
|
||||
<label>Scale <input class="control" name="scale" type="number" required></label>
|
||||
<button class="button primary" type="submit">Generate</button>
|
||||
<output id="generator-status" role="status"></output>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="map-editor-dialog" class="map-editor-dialog" aria-label="Planet surface maps"><button class="icon-button dialog-close" type="button" data-action="close-maps" aria-label="Close map editor">×</button><div id="map-editor"></div></dialog>
|
||||
<div id="workbench-notice" class="workbench-notice" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<?php return ob_get_clean();
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
return [
|
||||
'about' => 'Shared theme shell fragments rendered through the component system.',
|
||||
|
||||
'option' => function($prop) {
|
||||
$key = (string)($prop['key'] ?? '');
|
||||
$default = $prop['default'] ?? null;
|
||||
$themes = cfg('theme/options');
|
||||
$currentThemeKey = (string)cfg('theme/key');
|
||||
$currentTheme = is_array($themes) && isset($themes[$currentThemeKey]) ? $themes[$currentThemeKey] : [];
|
||||
if(is_array($currentTheme) && array_key_exists($key, $currentTheme)) return $currentTheme[$key];
|
||||
$direct = cfg('theme/'.$key);
|
||||
return $direct !== null ? $direct : $default;
|
||||
},
|
||||
|
||||
'html_class' => function($prop) {
|
||||
$classes = ['no-js'];
|
||||
if(cfg('theme/mode') === 'dark') $classes[] = 'dark-theme';
|
||||
$extra = trim((string)($prop['extra_class'] ?? ''));
|
||||
if($extra !== '') $classes[] = $extra;
|
||||
return trim(implode(' ', $classes));
|
||||
},
|
||||
|
||||
'head' => function($prop, $component) {
|
||||
$themePath = (string)cfg('theme/path');
|
||||
$themePathCommon = 'themes/common/';
|
||||
$description = first($prop['description'] ?? false, $component['option'](['key' => 'meta_description', 'default' => '']), 'Web App Starter theme');
|
||||
$themeColor = first($prop['theme_color'] ?? false, $component['option'](['key' => 'theme_color', 'default' => '']), '#0f172a');
|
||||
include_css($themePath.'css/style.css');
|
||||
include_js('js/u-query.js');
|
||||
include_js('js/morphdom.js');
|
||||
include_js('js/site.js');
|
||||
foreach((array)($prop['extra_css'] ?? []) as $extraCss) include_css($extraCss);
|
||||
foreach((array)($prop['extra_js'] ?? []) as $extraJs) include_js($extraJs);
|
||||
ob_start(); ?>
|
||||
<meta charset="utf-8">
|
||||
<title><?= first(URL::$route['page-title'] ?? false, cfg('site/default_page_title')).' | '.cfg('site/name') ?></title>
|
||||
<meta name="description" content="<?= asafe((string)$description) ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="<?= asafe((string)$themeColor) ?>">
|
||||
<?php return ob_get_clean();
|
||||
},
|
||||
|
||||
'menu_href' => function($prop) {
|
||||
$menuKey = (string)($prop['menu_key'] ?? '');
|
||||
$menuItem = is_array($prop['menu_item'] ?? null) ? $prop['menu_item'] : [];
|
||||
if(!empty($menuItem['external'])) return '/'.ltrim($menuKey, '/');
|
||||
return URL::Link($menuKey);
|
||||
},
|
||||
|
||||
'standard_nav' => function($prop, $component) {
|
||||
$navClass = trim((string)($prop['nav_class'] ?? ''));
|
||||
ob_start(); ?>
|
||||
<nav<?= $navClass !== '' ? ' class="'.asafe($navClass).'"' : '' ?>>
|
||||
<div class="nav-menu">
|
||||
<a href="<?= asafe(URL::Link('')) ?>"><?= safe((string)cfg('site/name')) ?></a>
|
||||
<?php foreach((array)cfg('menu') as $menuKey => $menuItem): if(empty($menuItem['hidden'])): ?>
|
||||
<a href="<?= asafe($component['menu_href'](['menu_key' => $menuKey, 'menu_item' => $menuItem])) ?>"><?= safe($menuItem['title']) ?></a>
|
||||
<?php endif; endforeach; ?>
|
||||
</div>
|
||||
</nav>
|
||||
<?php return ob_get_clean();
|
||||
},
|
||||
|
||||
|
||||
'footer' => function($prop, $component) {
|
||||
if(!empty($prop['embed_mode'])) return '';
|
||||
$footerText = first($prop['text'] ?? false, $component['option'](['key' => 'footer_text', 'default' => false]), cfg('site/name'));
|
||||
$innerClass = trim((string)($prop['inner_class'] ?? ''));
|
||||
ob_start(); ?>
|
||||
<footer>
|
||||
<?php if($innerClass !== ''): ?>
|
||||
<div class="<?= asafe($innerClass) ?>"><p><?= safe((string)$footerText) ?></p></div>
|
||||
<?php else: ?>
|
||||
<div style="max-width: 1200px; margin: 0 auto; padding: 0 1rem;"><p><?= safe((string)$footerText) ?></p></div>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
<?php return ob_get_clean();
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
chdir(__DIR__);
|
||||
require 'config/settings.php';
|
||||
require 'lib/ulib.php';
|
||||
require 'lib/components.php';
|
||||
|
||||
URL::$route = array_merge($GLOBALS['config']['url'], ['page' => 'index', 'l-path' => '']);
|
||||
if (isset($_GET['api'])) {
|
||||
require 'views/api/index.php';
|
||||
exit;
|
||||
}
|
||||
Profiler::log('main content: start', 1);
|
||||
ob_start();
|
||||
require 'views/index.php';
|
||||
URL::$fragments['main'] = ob_get_clean();
|
||||
Profiler::log('main content: end', -1);
|
||||
require cfg('theme/path') . '/page.html.php';
|
||||
Log::audit('page:index', '');
|
||||
@@ -0,0 +1,41 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const CHANNELS = [
|
||||
{ channel: 'red', name: 'ts', label: 'Surface temperature', unit: 'K', factor: 1 },
|
||||
{ channel: 'green', name: 'pr', label: 'Precipitation', unit: 'mm/day', factor: 86400000 },
|
||||
{ channel: 'blue', name: 'sg', label: 'Elevation', unit: 'm', factor: null }
|
||||
];
|
||||
|
||||
function create(index, gravity) {
|
||||
if (!index || !Array.isArray(index.fields) || !Number.isFinite(gravity) || gravity <= 0) return null;
|
||||
const fields = CHANNELS.map(channel => {
|
||||
const field = index.fields.find(item => item.name === channel.name);
|
||||
if (!field || !Array.isArray(field.shape) || field.shape.length !== 4 || field.shape[1] !== 1 || !field.shape[0] || field.shape[2] < 1 || field.shape[3] < 1 || !Number.isFinite(field.min) || !Number.isFinite(field.max)) return null;
|
||||
const expectedUnit = channel.name === 'ts' ? 'K' : channel.name === 'pr' ? 'm s-1' : 'm2 s-2';
|
||||
if (field.unit !== expectedUnit) return null;
|
||||
return Object.assign({}, channel, {
|
||||
factor: channel.factor === null ? 1 / gravity : channel.factor,
|
||||
min: field.min * (channel.factor === null ? 1 / gravity : channel.factor),
|
||||
max: field.max * (channel.factor === null ? 1 / gravity : channel.factor),
|
||||
shape: field.shape.slice()
|
||||
});
|
||||
});
|
||||
if (fields.some(field => !field || !Number.isFinite(field.min) || !Number.isFinite(field.max) || field.max < field.min)) return null;
|
||||
const [reference] = fields;
|
||||
if (fields.some(field => field.shape[2] !== reference.shape[2] || field.shape[3] !== reference.shape[3])) return null;
|
||||
const timeCount = Math.max(...fields.map(field => field.shape[0]));
|
||||
if (fields.some(field => field.shape[0] !== 1 && field.shape[0] !== timeCount)) return null;
|
||||
return { channels: fields, timeCount };
|
||||
}
|
||||
|
||||
function normalise(value, channel) {
|
||||
if (!channel || !Number.isFinite(value)) return null;
|
||||
const converted = value * channel.factor;
|
||||
if (!Number.isFinite(converted)) return null;
|
||||
if (channel.max === channel.min) return converted === 0 ? 0 : 0.5;
|
||||
return Math.max(0, Math.min(1, (converted - channel.min) / (channel.max - channel.min)));
|
||||
}
|
||||
|
||||
root.ExoComposite = { create, normalise };
|
||||
})(window);
|
||||
@@ -0,0 +1,161 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
const radians = Math.PI / 180;
|
||||
const clamp = (value, range) => Math.max(range.min, Math.min(range.max, value));
|
||||
function paint(values, lat, lon, center, radius, strength, tool, range) {
|
||||
const source = tool === 'smooth' ? values.slice() : values;
|
||||
const phi = center.lat * radians, limit = Math.cos(radius * radians), cols = lon.length, sine = Math.sin(phi), cosine = Math.cos(phi);
|
||||
let changed = false;
|
||||
for (let y = 0; y < lat.length; y++) {
|
||||
const latitude = lat[y] * radians, offset = sine * Math.sin(latitude), scale = cosine * Math.cos(latitude);
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const dot = offset + scale * Math.cos((lon[x] - center.lon) * radians);
|
||||
if (dot < limit) continue;
|
||||
const i = y * cols + x;
|
||||
let value = values[i];
|
||||
if (tool === 'set') value = strength;
|
||||
else if (tool === 'smooth') {
|
||||
const above = Math.max(0, y - 1), below = Math.min(lat.length - 1, y + 1);
|
||||
value = (source[above * cols + x] + source[below * cols + x] + source[y * cols + (x + cols - 1) % cols] + source[y * cols + (x + 1) % cols] + source[i]) / 5;
|
||||
} else {
|
||||
const weight = (dot - limit) / (1 - limit);
|
||||
value += (tool === 'lower' ? -1 : 1) * Math.abs(strength) * weight;
|
||||
}
|
||||
value = Math.fround(clamp(value, range));
|
||||
if (value !== values[i]) { values[i] = value; changed = true; }
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
window.ExoMapBrush = paint;
|
||||
window.ExoMapEditor = function (root, options) {
|
||||
let values = null, original = null, grid = null, rule = null, dirty = false, busy = false, saving = false, token = 0;
|
||||
let undo = [], redo = [], stroke = null, raf = 0, pending = null, last = null, range = {min:0,max:1};
|
||||
const raster=document.createElement('canvas'),rasterContext=raster.getContext('2d');
|
||||
let rasterImage=null,rasterRows=[];
|
||||
root.innerHTML = `<div class="map-editor-controls"><label>Map <select data-map-field></select></label><label>Source <select data-map-source></select></label><label>Name <input data-map-name maxlength="120"></label><label>Tool <select data-map-tool><option value="raise">Raise</option><option value="lower">Lower</option><option value="set">Set value</option><option value="smooth">Smooth</option></select></label><label>Radius (°) <input data-map-radius type="number" value="8" min="1" max="90" step="1"></label><label><span data-map-unit>Value</span> <input data-map-value type="number" step="any"></label><button type="button" data-map-ocean hidden>Ocean</button><button type="button" data-map-land hidden>Land</button><button type="button" data-map-undo>Undo</button><button type="button" data-map-redo>Redo</button><button type="button" data-map-save>Save map</button></div><canvas class="map-editor-canvas" width="640" height="320" tabindex="0" aria-label="Editable surface map. Arrow keys move the brush. Space paints."></canvas><div class="map-editor-scale"><span data-map-min></span><span data-map-max></span></div><output data-map-position></output><output data-map-status role="status"></output>`;
|
||||
const $ = selector => root.querySelector(selector), canvas = $('.map-editor-canvas'), ctx = canvas.getContext('2d');
|
||||
const field = $('[data-map-field]'), source = $('[data-map-source]'), tool = $('[data-map-tool]');
|
||||
const status = text => { $('[data-map-status]').textContent = text; };
|
||||
const base=document.createElement('canvas');base.width=canvas.width;base.height=canvas.height;const baseContext=base.getContext('2d');
|
||||
const listeners = new AbortController();
|
||||
const on = (node, event, callback) => node.addEventListener(event, callback, {signal:listeners.signal});
|
||||
let cursor = {lat:0,lon:0};
|
||||
function controls() {
|
||||
$('[data-map-undo]').disabled = busy || !undo.length;
|
||||
$('[data-map-redo]').disabled = busy || !redo.length;
|
||||
$('[data-map-save]').disabled = busy || !values || !dirty;
|
||||
root.querySelectorAll('input,select,[data-map-ocean],[data-map-land]').forEach(control=>control.disabled=busy);
|
||||
options.onDirty(dirty);
|
||||
}
|
||||
function nearest(coords, value, periodic=false) {
|
||||
let best = 0, distance = Infinity;
|
||||
coords.forEach((coordinate, i) => { let d = Math.abs(coordinate - value); if(periodic) { d %= 360; d = Math.min(d,360-d); } if(d < distance) { distance=d;best=i; } });
|
||||
return best;
|
||||
}
|
||||
function inspect() {
|
||||
if(!values)return;
|
||||
const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true);
|
||||
$('[data-map-position]').textContent=`${Number(grid.lat[y]).toFixed(2)}° latitude · ${Number(grid.lon[x]).toFixed(2)}° longitude · ${Number(values[y*grid.lon.length+x]).toPrecision(6)}${rule.unit==='1'?'':' '+rule.unit}`;
|
||||
}
|
||||
function drawCursor(){
|
||||
if(!values)return;ctx.drawImage(base,0,0);
|
||||
const radius=Number($('[data-map-radius]').value)*radians,phi=cursor.lat*radians,start=grid.lon[0]-(grid.lon.length>1?(grid.lon[1]-grid.lon[0])/2:180);
|
||||
ctx.strokeStyle='#fff';ctx.lineWidth=1.5;ctx.beginPath();let previous=null;
|
||||
for(let i=0;i<=64;i++){const angle=i*Math.PI/32,latitude=Math.asin(Math.sin(phi)*Math.cos(radius)+Math.cos(phi)*Math.sin(radius)*Math.cos(angle));const longitude=cursor.lon+Math.atan2(Math.sin(angle)*Math.sin(radius)*Math.cos(phi),Math.cos(radius)-Math.sin(phi)*Math.sin(latitude))/radians;
|
||||
const x=(((longitude-start)%360+360)%360)/360*canvas.width,y=(90-latitude/radians)/180*canvas.height;
|
||||
if(previous===null||Math.abs(x-previous)>canvas.width/2)ctx.moveTo(x,y);else ctx.lineTo(x,y);previous=x;
|
||||
}ctx.stroke();
|
||||
}
|
||||
function draw() {
|
||||
if(!values)return;
|
||||
const rows=grid.lat.length,cols=grid.lon.length;
|
||||
range={min:Infinity,max:-Infinity};for(const value of values){range.min=Math.min(range.min,value);range.max=Math.max(range.max,value);}
|
||||
if(!rasterImage){raster.width=cols;raster.height=rows*4;rasterImage=rasterContext.createImageData(cols,raster.height);rasterRows=Array.from({length:raster.height},(_,y)=>nearest(grid.lat,90-(y+.5)*180/raster.height));}
|
||||
const image=rasterImage;
|
||||
for(let y=0;y<raster.height;y++){
|
||||
const row=rasterRows[y];
|
||||
for(let x=0;x<cols;x++){
|
||||
const value=values[row*cols+x],t=range.max===range.min?.5:(value-range.min)/(range.max-range.min),i=(y*cols+x)*4;
|
||||
image.data[i]=rule.categorical?(value>=.5?208:12):35+210*t;
|
||||
image.data[i+1]=rule.categorical?(value>=.5?204:41):40+205*t;
|
||||
image.data[i+2]=rule.categorical?(value>=.5?165:63):48+197*t;
|
||||
image.data[i+3]=255;
|
||||
}
|
||||
}
|
||||
rasterContext.putImageData(image,0,0);ctx.imageSmoothingEnabled=false;ctx.drawImage(raster,0,0,canvas.width,canvas.height);baseContext.drawImage(canvas,0,0);drawCursor();
|
||||
$('[data-map-min]').textContent=`${range.min.toPrecision(6)} ${rule.unit}`;
|
||||
$('[data-map-max]').textContent=`${range.max.toPrecision(6)} ${rule.unit}`;
|
||||
inspect();controls();
|
||||
}
|
||||
function keepHistory(previous) { undo.push(previous);if(undo.length>32)undo.shift();redo=[];dirty=true; }
|
||||
function dab(point) {
|
||||
const radius=Number($('[data-map-radius]').value),strength=Number($('[data-map-value]').value);
|
||||
if(!Number.isFinite(radius)||radius<1||radius>90||!Number.isFinite(strength)){status('Enter a finite value and a radius from 1° to 90°.');return false;}
|
||||
if(rule.categorical&&strength!==rule.min&&strength!==rule.max){status('Choose Ocean or Land.');return false;}
|
||||
if(tool.value==='set'&&(strength<rule.min||strength>rule.max)){status(`Value must be from ${rule.min} to ${rule.max} ${rule.unit}.`);return false;}
|
||||
return paint(values,grid.lat,grid.lon,point,radius,strength,tool.value,rule);
|
||||
}
|
||||
function flush() {
|
||||
raf=0;if(!pending||busy||!values)return;
|
||||
const point=pending;pending=null;
|
||||
let changed=false;
|
||||
const radius=Number($('[data-map-radius]').value),distance=last?Math.hypot(point.lat-last.lat,((point.lon-last.lon+540)%360)-180):0;
|
||||
const steps=Math.min(8,Math.max(1,Math.ceil(distance/Math.max(1,radius/2))));
|
||||
for(let step=1;step<=steps;step++){const t=step/steps,delta=last?((point.lon-last.lon+540)%360)-180:0;changed=dab(last?{lat:last.lat+(point.lat-last.lat)*t,lon:last.lon+delta*t}:point)||changed;}
|
||||
if(changed){if(!stroke.changed){keepHistory(stroke.before);stroke.changed=true;}draw();status('Unsaved map changes.');}
|
||||
last=point;cursor=point;inspect();
|
||||
}
|
||||
function position(event) {
|
||||
const box=canvas.getBoundingClientRect(),step=grid.lon.length>1?grid.lon[1]-grid.lon[0]:360;
|
||||
return {lat:Math.max(-90,Math.min(90,90-(event.clientY-box.top)/box.height*180)),lon:grid.lon[0]-step/2+(event.clientX-box.left)/box.width*360};
|
||||
}
|
||||
on(canvas,'pointerdown',event=>{if(busy||!values||event.button!==0)return;event.preventDefault();canvas.focus({preventScroll:true});canvas.setPointerCapture(event.pointerId);stroke={before:values.slice(),changed:false};last=null;pending=position(event);flush();});
|
||||
on(canvas,'pointermove',event=>{if(!values)return;cursor=position(event);inspect();drawCursor();if(!stroke)return;pending=cursor;if(!raf)raf=requestAnimationFrame(flush);});
|
||||
const finish=()=>{if(raf){cancelAnimationFrame(raf);raf=0;}if(stroke&&pending)flush();stroke=null;last=null;pending=null;};
|
||||
on(canvas,'pointerup',finish);on(canvas,'pointercancel',finish);on(canvas,'lostpointercapture',finish);
|
||||
on(canvas,'keydown',event=>{if(!values||busy)return;const y=nearest(grid.lat,cursor.lat),x=nearest(grid.lon,cursor.lon,true);let row=y,col=x;
|
||||
if(event.key==='ArrowUp')row=Math.min(grid.lat.length-1,Math.max(0,y+(grid.lat[0]<grid.lat.at(-1)?1:-1)));
|
||||
else if(event.key==='ArrowDown')row=Math.min(grid.lat.length-1,Math.max(0,y+(grid.lat[0]<grid.lat.at(-1)?-1:1)));
|
||||
else if(event.key==='ArrowLeft')col=(x+grid.lon.length-1)%grid.lon.length;
|
||||
else if(event.key==='ArrowRight')col=(x+1)%grid.lon.length;
|
||||
else if(event.key===' '){event.preventDefault();const before=values.slice();if(dab(cursor)){keepHistory(before);draw();}return;}else return;
|
||||
event.preventDefault();cursor={lat:grid.lat[row],lon:grid.lon[col]};inspect();drawCursor();
|
||||
});
|
||||
on($('[data-map-ocean]'),'click',()=>{$('[data-map-value]').value=rule.min;});
|
||||
on($('[data-map-land]'),'click',()=>{$('[data-map-value]').value=rule.max;});
|
||||
on($('[data-map-radius]'),'input',drawCursor);
|
||||
on($('[data-map-undo]'),'click',()=>{if(!undo.length||busy)return;redo.push(values.slice());values=undo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');});
|
||||
on($('[data-map-redo]'),'click',()=>{if(!redo.length||busy)return;undo.push(values.slice());values=redo.pop();dirty=values.some((value,i)=>value!==original[i]);draw();status(dirty?'Unsaved map changes.':'');});
|
||||
async function load() {
|
||||
const current=++token;busy=true;controls();ctx.clearRect(0,0,canvas.width,canvas.height);for(const selector of ['[data-map-min]','[data-map-max]','[data-map-position]'])$(selector).textContent='';status('Loading map…');
|
||||
try { const loaded=await options.load(field.value,source.value);if(current!==token)return;
|
||||
values=loaded.values.slice();original=values.slice();grid=loaded.grid;rule=options.fields[field.value];rasterImage=null;
|
||||
if(values.length!==grid.lat.length*grid.lon.length||Array.from(values).some(v=>!Number.isFinite(v)))throw Error('Map data does not match its grid.');
|
||||
undo=[];redo=[];dirty=false;cursor={lat:0,lon:0};$('[data-map-name]').value=loaded.name||rule.label;
|
||||
$('[data-map-unit]').textContent=rule.unit==='1'?'Value':`Value (${rule.unit})`;
|
||||
$('[data-map-ocean]').hidden=$('[data-map-land]').hidden=!rule.categorical;$('[data-map-value]').value=rule.categorical?rule.max:(rule.brush_step??(rule.max-rule.min)/100);
|
||||
for(const option of tool.options)option.disabled=Boolean(rule.categorical)&&option.value!=='set';tool.value=rule.categorical?'set':'raise';
|
||||
draw();status('');
|
||||
}catch(error){if(current===token){values=null;ctx.clearRect(0,0,canvas.width,canvas.height);status(error.message);}}
|
||||
finally{if(current===token){busy=false;controls();}}
|
||||
}
|
||||
async function sources() {
|
||||
const entries=await options.sources(field.value);source.replaceChildren();
|
||||
for(const entry of entries){const option=document.createElement('option');option.value=entry.id;option.textContent=entry.name;source.append(option);}
|
||||
await load();
|
||||
}
|
||||
function canDiscard(){return !dirty||confirm('Discard unsaved map changes?');}
|
||||
let previousField='',previousSource='';
|
||||
on(field,'change',async()=>{if(!canDiscard()){field.value=previousField;return;}previousField=field.value;await sources();previousSource=source.value;});
|
||||
on(source,'change',async()=>{if(!canDiscard()){source.value=previousSource;return;}previousSource=source.value;await load();});
|
||||
on($('[data-map-save]'),'click',async()=>{if(busy||!values)return;finish();const name=$('[data-map-name]').value.trim();if(!name){status('Enter a map name.');return;}busy=true;saving=true;controls();status('Saving map…');
|
||||
try{const saved=await options.save(field.value,name,values.slice());dirty=false;original=values.slice();undo=[];redo=[];status('Map saved. Save the experiment to use it.');options.onSaved(field.value,saved);}
|
||||
catch(error){status(error.message);}finally{busy=false;saving=false;controls();}
|
||||
});
|
||||
for(const [name,metadata]of Object.entries(options.fields)){const option=document.createElement('option');option.value=name;option.textContent=metadata.label+(metadata.unit==='1'?'':` (${metadata.unit})`);field.append(option);}
|
||||
this.start=async()=>{previousField=field.value;await sources();previousSource=source.value;};
|
||||
this.canClose=()=>!saving&&canDiscard();
|
||||
this.destroy=()=>{token++;listeners.abort();if(raf)cancelAnimationFrame(raf);root.replaceChildren();};
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):(global=global||self,global.morphdom=factory())})(this,function(){"use strict";var DOCUMENT_FRAGMENT_NODE=11;function morphAttrs(fromNode,toNode){var toNodeAttrs=toNode.attributes;var attr;var attrName;var attrNamespaceURI;var attrValue;var fromValue;if(toNode.nodeType===DOCUMENT_FRAGMENT_NODE||fromNode.nodeType===DOCUMENT_FRAGMENT_NODE){return}for(var i=toNodeAttrs.length-1;i>=0;i--){attr=toNodeAttrs[i];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;attrValue=attr.value;if(attrNamespaceURI){attrName=attr.localName||attrName;fromValue=fromNode.getAttributeNS(attrNamespaceURI,attrName);if(fromValue!==attrValue){if(attr.prefix==="xmlns"){attrName=attr.name}fromNode.setAttributeNS(attrNamespaceURI,attrName,attrValue)}}else{fromValue=fromNode.getAttribute(attrName);if(fromValue!==attrValue){fromNode.setAttribute(attrName,attrValue)}}}var fromNodeAttrs=fromNode.attributes;for(var d=fromNodeAttrs.length-1;d>=0;d--){attr=fromNodeAttrs[d];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;if(attrNamespaceURI){attrName=attr.localName||attrName;if(!toNode.hasAttributeNS(attrNamespaceURI,attrName)){fromNode.removeAttributeNS(attrNamespaceURI,attrName)}}else{if(!toNode.hasAttribute(attrName)){fromNode.removeAttribute(attrName)}}}}var range;var NS_XHTML="http://www.w3.org/1999/xhtml";var doc=typeof document==="undefined"?undefined:document;var HAS_TEMPLATE_SUPPORT=!!doc&&"content"in doc.createElement("template");var HAS_RANGE_SUPPORT=!!doc&&doc.createRange&&"createContextualFragment"in doc.createRange();function createFragmentFromTemplate(str){var template=doc.createElement("template");template.innerHTML=str;return template.content.childNodes[0]}function createFragmentFromRange(str){if(!range){range=doc.createRange();range.selectNode(doc.body)}var fragment=range.createContextualFragment(str);return fragment.childNodes[0]}function createFragmentFromWrap(str){var fragment=doc.createElement("body");fragment.innerHTML=str;return fragment.childNodes[0]}function toElement(str){str=str.trim();if(HAS_TEMPLATE_SUPPORT){return createFragmentFromTemplate(str)}else if(HAS_RANGE_SUPPORT){return createFragmentFromRange(str)}return createFragmentFromWrap(str)}function compareNodeNames(fromEl,toEl){var fromNodeName=fromEl.nodeName;var toNodeName=toEl.nodeName;var fromCodeStart,toCodeStart;if(fromNodeName===toNodeName){return true}fromCodeStart=fromNodeName.charCodeAt(0);toCodeStart=toNodeName.charCodeAt(0);if(fromCodeStart<=90&&toCodeStart>=97){return fromNodeName===toNodeName.toUpperCase()}else if(toCodeStart<=90&&fromCodeStart>=97){return toNodeName===fromNodeName.toUpperCase()}else{return false}}function createElementNS(name,namespaceURI){return!namespaceURI||namespaceURI===NS_XHTML?doc.createElement(name):doc.createElementNS(namespaceURI,name)}function moveChildren(fromEl,toEl){var curChild=fromEl.firstChild;while(curChild){var nextChild=curChild.nextSibling;toEl.appendChild(curChild);curChild=nextChild}return toEl}function syncBooleanAttrProp(fromEl,toEl,name){if(fromEl[name]!==toEl[name]){fromEl[name]=toEl[name];if(fromEl[name]){fromEl.setAttribute(name,"")}else{fromEl.removeAttribute(name)}}}var specialElHandlers={OPTION:function(fromEl,toEl){var parentNode=fromEl.parentNode;if(parentNode){var parentName=parentNode.nodeName.toUpperCase();if(parentName==="OPTGROUP"){parentNode=parentNode.parentNode;parentName=parentNode&&parentNode.nodeName.toUpperCase()}if(parentName==="SELECT"&&!parentNode.hasAttribute("multiple")){if(fromEl.hasAttribute("selected")&&!toEl.selected){fromEl.setAttribute("selected","selected");fromEl.removeAttribute("selected")}parentNode.selectedIndex=-1}}syncBooleanAttrProp(fromEl,toEl,"selected")},INPUT:function(fromEl,toEl){syncBooleanAttrProp(fromEl,toEl,"checked");syncBooleanAttrProp(fromEl,toEl,"disabled");if(fromEl.value!==toEl.value){fromEl.value=toEl.value}if(!toEl.hasAttribute("value")){fromEl.removeAttribute("value")}},TEXTAREA:function(fromEl,toEl){var newValue=toEl.value;if(fromEl.value!==newValue){fromEl.value=newValue}var firstChild=fromEl.firstChild;if(firstChild){var oldValue=firstChild.nodeValue;if(oldValue==newValue||!newValue&&oldValue==fromEl.placeholder){return}firstChild.nodeValue=newValue}},SELECT:function(fromEl,toEl){if(!toEl.hasAttribute("multiple")){var selectedIndex=-1;var i=0;var curChild=fromEl.firstChild;var optgroup;var nodeName;while(curChild){nodeName=curChild.nodeName&&curChild.nodeName.toUpperCase();if(nodeName==="OPTGROUP"){optgroup=curChild;curChild=optgroup.firstChild}else{if(nodeName==="OPTION"){if(curChild.hasAttribute("selected")){selectedIndex=i;break}i++}curChild=curChild.nextSibling;if(!curChild&&optgroup){curChild=optgroup.nextSibling;optgroup=null}}}fromEl.selectedIndex=selectedIndex}}};var ELEMENT_NODE=1;var DOCUMENT_FRAGMENT_NODE$1=11;var TEXT_NODE=3;var COMMENT_NODE=8;function noop(){}functionLine truncated
|
||||
@@ -0,0 +1,65 @@
|
||||
var enable_debug = true;
|
||||
|
||||
var UI = {
|
||||
|
||||
smoothScrollToNamedAnchors: function() {
|
||||
$('a[href^="#"]').each(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
enablePageTransitions: function() {
|
||||
$(document.body).append(`<style>
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.25s;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation-name: fade-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: fade-in;
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}</style>`);
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
document.startViewTransition(() => {
|
||||
window.location.href = link.href;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
init: function() {
|
||||
//UI.enablePageTransitions();
|
||||
UI.smoothScrollToNamedAnchors();
|
||||
document.body.classList.add('loaded');
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
$.ready(UI.init);
|
||||
@@ -0,0 +1,137 @@
|
||||
# macrobars.js
|
||||
|
||||
A lightweight JavaScript templating engine with PHP-style syntax and Handlebars-inspired features.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```javascript
|
||||
const template = Macrobars.compile('<h1>{{title}}</h1><p>{{content}}</p>');
|
||||
const html = template({title: 'Hello', content: 'World'});
|
||||
```
|
||||
|
||||
## Field Output
|
||||
|
||||
```javascript
|
||||
{{field}} // Safe HTML output (escaped)
|
||||
{{{field}}} // Unsafe HTML output (raw)
|
||||
{{field or "default"}} // With default value
|
||||
{{:variable}} // Direct variable (no data. prefix)
|
||||
```
|
||||
|
||||
## Numbers
|
||||
|
||||
```javascript
|
||||
{{%number}} // Formatted number (2 decimals)
|
||||
{{~number}} // Rounded number (1k, 1M format)
|
||||
```
|
||||
|
||||
## Control Structures
|
||||
|
||||
### Conditionals
|
||||
```javascript
|
||||
{{#if condition}}
|
||||
Content when true
|
||||
{{#else}}
|
||||
Content when false
|
||||
{{/if}}
|
||||
```
|
||||
|
||||
### Equality Blocks
|
||||
```javascript
|
||||
{{#eq value1 value2}}
|
||||
Content when equal
|
||||
{{/eq}}
|
||||
|
||||
{{eq value1 value2}} // Inline: outputs "true" or ""
|
||||
```
|
||||
|
||||
### Loops
|
||||
```javascript
|
||||
{{#each items}}
|
||||
{{name}} - {{index}}
|
||||
{{/each}}
|
||||
|
||||
{{#each items as item}}
|
||||
{{item.name}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
### Property Lookup
|
||||
```javascript
|
||||
{{#lookup object key}}
|
||||
{{value}} exists
|
||||
{{/lookup}}
|
||||
|
||||
{{lookup object key}} // Inline: outputs value or ""
|
||||
```
|
||||
|
||||
## Code Blocks
|
||||
|
||||
```javascript
|
||||
<script>
|
||||
var result = data.value * 2;
|
||||
</script>
|
||||
|
||||
<defer>
|
||||
// Deferred JavaScript execution
|
||||
</defer>
|
||||
|
||||
<?= data.field ?> // PHP-style output
|
||||
<? var x = data.count; ?> // PHP-style code
|
||||
```
|
||||
|
||||
## Event Binding
|
||||
|
||||
```javascript
|
||||
<button {{@click="handleClick"}}>Click me</button>
|
||||
```
|
||||
|
||||
Events are automatically bound when using `renderTo()`.
|
||||
|
||||
## Components
|
||||
|
||||
```javascript
|
||||
{{#component myComponent}} // Render registered component
|
||||
```
|
||||
|
||||
## Compilation Options
|
||||
|
||||
```javascript
|
||||
const template = Macrobars.compile(templateString, {
|
||||
decimals: 2, // Number formatting precision
|
||||
strict: true, // Use strict mode
|
||||
components: {...} // Reusable components
|
||||
});
|
||||
```
|
||||
|
||||
## DOM Integration
|
||||
|
||||
```javascript
|
||||
template.renderTo('#container', data); // Render to element
|
||||
template.renderTo(document.getElementById('x'), data);
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
```javascript
|
||||
Macrobars.safe_out(text, default) // HTML escape
|
||||
Macrobars.num_out(number, decimals) // Format number
|
||||
Macrobars.num_out_round(number) // Round with k/M suffix
|
||||
```
|
||||
|
||||
## Template Debugging
|
||||
|
||||
Access compiled template information:
|
||||
```javascript
|
||||
template.tokens // Parsed tokens
|
||||
template.gensource // Generated JavaScript
|
||||
template.event_bindings // Event binding data
|
||||
```
|
||||
|
||||
## Default Values
|
||||
|
||||
```javascript
|
||||
{{#default "N/A"}} // Set default for empty fields
|
||||
{{field}} // Will show "N/A" if empty
|
||||
```
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
(function (root, factory) {
|
||||
// this is the stupid UMD pattern, but eh we lost that battle a long time ago
|
||||
if (typeof exports === 'object' && typeof module !== 'undefined') {
|
||||
var exports_obj = factory();
|
||||
module.exports = exports_obj.$;
|
||||
// Also export individual components
|
||||
for (var key in exports_obj) {
|
||||
if (key !== '$' && exports_obj.hasOwnProperty(key)) {
|
||||
exports[key] = exports_obj[key];
|
||||
}
|
||||
}
|
||||
} else if (typeof define === 'function' && define.amd) {
|
||||
define([], factory);
|
||||
} else {
|
||||
var exports_obj = factory();
|
||||
root.$ = exports_obj.$;
|
||||
for (var key in exports_obj) {
|
||||
if (key !== '$' && exports_obj.hasOwnProperty(key)) {
|
||||
root[key] = exports_obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}(typeof self !== 'undefined' ? self : this, function () {
|
||||
|
||||
// a collection of DOM utility functions that I thought were cool in jQuery
|
||||
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
/** Map<eventName, Map<slotKey|Symbol, Function>> */
|
||||
this._topics = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to `topic`. If `slot_key` is provided (truthy),
|
||||
* it dedupes by that key; otherwise a unique Symbol is used.
|
||||
* Returns an unsubscribe fn.
|
||||
*/
|
||||
on(topic, handler, slot_key = null) {
|
||||
let map = this._topics.get(topic);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this._topics.set(topic, map);
|
||||
}
|
||||
// use the provided slot_key or a fresh Symbol()
|
||||
const key = slot_key != null ? slot_key : Symbol();
|
||||
map.set(key, handler);
|
||||
return () => this.off(topic, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe by handler function or by slot_key.
|
||||
*/
|
||||
off(topic, handlerOrSlotKey) {
|
||||
const map = this._topics.get(topic);
|
||||
if (!map) return;
|
||||
|
||||
// if it matches a slotKey directly, remove it
|
||||
if (map.has(handlerOrSlotKey)) {
|
||||
map.delete(handlerOrSlotKey);
|
||||
} else {
|
||||
// otherwise assume it's a function: remove all matching fns
|
||||
for (const [key, fn] of map.entries()) {
|
||||
if (fn === handlerOrSlotKey) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (map.size === 0) {
|
||||
this._topics.delete(topic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit to all handlers on `topic`. Handlers returning
|
||||
* 'remove_handler' are auto-removed.
|
||||
* Returns the number of handlers invoked.
|
||||
*/
|
||||
emit(topic, ...args) {
|
||||
let count = 0;
|
||||
const map = this._topics.get(topic);
|
||||
if (!map) return count;
|
||||
|
||||
for (const [key, fn] of Array.from(map.entries())) {
|
||||
const res = fn(...args);
|
||||
count++;
|
||||
if (res === 'remove_handler') {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (map.size === 0) {
|
||||
this._topics.delete(topic);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
class QueryWrapper extends Array {
|
||||
constructor(elements) {
|
||||
super();
|
||||
if (elements) {
|
||||
this.push(...(Array.isArray(elements) ? elements : [elements]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ = function(selector_or_element) {
|
||||
let elements;
|
||||
if (selector_or_element instanceof Element || selector_or_element === document || selector_or_element === window) {
|
||||
elements = [selector_or_element];
|
||||
} else if (typeof selector_or_element === 'string') {
|
||||
// Check if string looks like HTML (starts with < and ends with >)
|
||||
if (selector_or_element.trim().charAt(0) === '<' && selector_or_element.trim().charAt(selector_or_element.trim().length - 1) === '>') {
|
||||
// Create element from HTML string
|
||||
let temp = document.createElement('div');
|
||||
temp.innerHTML = selector_or_element.trim();
|
||||
elements = Array.from(temp.children);
|
||||
} else {
|
||||
// Treat as CSS selector
|
||||
elements = Array.from(document.querySelectorAll(selector_or_element));
|
||||
}
|
||||
} else if (selector_or_element instanceof NodeList || Array.isArray(selector_or_element)) {
|
||||
elements = Array.from(selector_or_element);
|
||||
} else {
|
||||
elements = [selector_or_element];
|
||||
}
|
||||
return new QueryWrapper(elements);
|
||||
};
|
||||
|
||||
$.options = {
|
||||
alwaysDoDifferentialUpdate: true,
|
||||
};
|
||||
|
||||
$.events = new EventEmitter();
|
||||
$.on = $.events.on.bind($.events);
|
||||
$.off = $.events.off.bind($.events);
|
||||
$.emit = $.events.emit.bind($.events);
|
||||
|
||||
$.each = function(selector, callback) {
|
||||
let elements;
|
||||
if (typeof selector === 'string') {
|
||||
elements = document.querySelectorAll(selector);
|
||||
} else if (selector instanceof QueryWrapper) {
|
||||
elements = selector;
|
||||
} else {
|
||||
elements = selector;
|
||||
}
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
callback(elements[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
$.post = function(url, data, callback) {
|
||||
return $.ajax({
|
||||
method: 'POST',
|
||||
url: url,
|
||||
data: data,
|
||||
success: callback,
|
||||
error: function(xhr) {
|
||||
console.error('POST request failed:', xhr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.get = function(url, callback) {
|
||||
return $.ajax({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
success: callback,
|
||||
error: function(xhr) {
|
||||
console.error('GET request failed:', xhr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.ajax = function(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open(options.method || 'GET', options.url, true);
|
||||
|
||||
// Set default headers
|
||||
if (options.method === 'POST' || options.data) {
|
||||
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
|
||||
}
|
||||
|
||||
// Set custom headers
|
||||
if (options.headers) {
|
||||
for (var key in options.headers) {
|
||||
xhr.setRequestHeader(key, options.headers[key]);
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
var response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, use raw response
|
||||
response = xhr.responseText;
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (options.success) options.success(response);
|
||||
resolve(response);
|
||||
} else {
|
||||
if (options.error) options.error(xhr);
|
||||
reject(xhr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var data = null;
|
||||
if (options.data) {
|
||||
data = typeof options.data === 'string' ? options.data : JSON.stringify(options.data);
|
||||
}
|
||||
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
$.ready = function(callback) {
|
||||
if (document.readyState !== 'loading') {
|
||||
callback();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to execute scripts in HTML content
|
||||
function executeScripts(htmlContent) {
|
||||
// Create a temporary container to parse the HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Find all script tags and disable them temporarily
|
||||
const scripts = tempDiv.querySelectorAll('script');
|
||||
const scriptData = [];
|
||||
|
||||
scripts.forEach((script) => {
|
||||
// Store script data for later execution
|
||||
scriptData.push({
|
||||
content: script.textContent || script.innerText || '',
|
||||
src: script.src,
|
||||
type: script.type,
|
||||
nonce: script.nonce,
|
||||
async: script.async,
|
||||
defer: script.defer
|
||||
});
|
||||
|
||||
// Disable the script by changing its type
|
||||
script.type = 'text/disabled-script';
|
||||
});
|
||||
|
||||
// Return the modified HTML and script data
|
||||
return {
|
||||
html: tempDiv.innerHTML,
|
||||
scripts: scriptData
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to execute a single script
|
||||
function executeScript(scriptInfo) {
|
||||
if (scriptInfo.src) {
|
||||
// External script
|
||||
const newScript = document.createElement('script');
|
||||
if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') {
|
||||
newScript.type = scriptInfo.type;
|
||||
}
|
||||
if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce;
|
||||
if (scriptInfo.async) newScript.async = scriptInfo.async;
|
||||
if (scriptInfo.defer) newScript.defer = scriptInfo.defer;
|
||||
newScript.src = scriptInfo.src;
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
} else if (scriptInfo.content.trim()) {
|
||||
// Inline script
|
||||
const newScript = document.createElement('script');
|
||||
if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') {
|
||||
newScript.type = scriptInfo.type;
|
||||
}
|
||||
if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce;
|
||||
newScript.text = scriptInfo.content;
|
||||
|
||||
// Execute by inserting and immediately removing
|
||||
document.head.appendChild(newScript);
|
||||
document.head.removeChild(newScript);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all the jQuery-like methods to QueryWrapper
|
||||
QueryWrapper.prototype.parent = function() {
|
||||
const parents = Array.from(this).map(el => el.parentNode).filter(el => el);
|
||||
return new QueryWrapper(parents);
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.load = function(url, opt = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ajaxOptions = {
|
||||
method: opt.method || (opt.postData ? 'POST' : 'GET'),
|
||||
url: url,
|
||||
success: (response) => {
|
||||
if(opt.replace)
|
||||
this.replaceWith(response, opt);
|
||||
else
|
||||
this.html(response, opt);
|
||||
if (opt.onLoad) opt.onLoad(response, null);
|
||||
resolve(this);
|
||||
},
|
||||
error: (xhr) => {
|
||||
if (opt.onError) opt.onError(xhr);
|
||||
reject(new Error('Failed to load: ' + url));
|
||||
}
|
||||
};
|
||||
|
||||
if (opt.postData) {
|
||||
ajaxOptions.data = opt.postData;
|
||||
}
|
||||
|
||||
$.ajax(ajaxOptions);
|
||||
});
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.html = function(opt_content = false, prop = {}) {
|
||||
if (opt_content === false) {
|
||||
return Array.from(this).map(el => el.innerHTML).join('');
|
||||
}
|
||||
if(typeof prop.diff == 'undefined')
|
||||
prop.diff = $.options.alwaysDoDifferentialUpdate;
|
||||
if(prop.diff === true) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = opt_content;
|
||||
this.forEach(el => {
|
||||
morphdom(el, temp, { childrenOnly: true });
|
||||
});
|
||||
} else {
|
||||
this.forEach(el => {
|
||||
const result = executeScripts(opt_content);
|
||||
el.innerHTML = result.html;
|
||||
result.scripts.forEach(executeScript);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.text = function(opt_content = false) {
|
||||
if (opt_content === false) {
|
||||
return Array.from(this).map(el => el.textContent).join('');
|
||||
}
|
||||
this.forEach(el => {
|
||||
el.textContent = opt_content;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.replaceWith = function(content, prop = {}) {
|
||||
if(typeof prop.diff == 'undefined')
|
||||
prop.diff = $.options.alwaysDoDifferentialUpdate;
|
||||
if(prop.diff === true) {
|
||||
this.forEach(el => {
|
||||
morphdom(el, content);
|
||||
});
|
||||
} else {
|
||||
if(typeof content === 'string') {
|
||||
this.forEach(el => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content;
|
||||
el.parentNode.replaceChild(tempDiv.firstChild, el);
|
||||
});
|
||||
} else if(content instanceof Element) {
|
||||
this.forEach(el => {
|
||||
el.parentNode.replaceChild(content.cloneNode(true), el);
|
||||
});
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.query = function(selector) {
|
||||
const found = Array.from(this).reduce((acc, el) => {
|
||||
const results = el.querySelectorAll(selector);
|
||||
return acc.concat(Array.from(results));
|
||||
}, []);
|
||||
return new QueryWrapper(found);
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.find = function(selector) {
|
||||
const found = Array.from(this).reduce((acc, el) => {
|
||||
const results = el.querySelectorAll(selector);
|
||||
return acc.concat(Array.from(results));
|
||||
}, []);
|
||||
return new QueryWrapper(found);
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.on = function(event, handler) {
|
||||
this.forEach(function(el) {
|
||||
el.addEventListener(event, handler);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.off = function(event, handler) {
|
||||
this.forEach(function(el) {
|
||||
el.removeEventListener(event, handler);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.hide = function() {
|
||||
return this.css({ display: 'none' });
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.show = function() {
|
||||
return this.css({ display: '' });
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.toggle = function() {
|
||||
this.forEach(function(el) {
|
||||
el.style.display = (el.style.display === 'none' || el.style.display === '') ? '' : 'none';
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.attr = function(name, value) {
|
||||
if (value === undefined) {
|
||||
return this.length > 0 ? this[0].getAttribute(name) : null;
|
||||
}
|
||||
this.forEach(function(el) {
|
||||
el.setAttribute(name, value);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.addClass = function(classNameOrList) {
|
||||
var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList];
|
||||
this.forEach(function(el) {
|
||||
classList.forEach(function(classNames) {
|
||||
// Split space-separated class names
|
||||
var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; });
|
||||
classes.forEach(function(className) {
|
||||
el.classList.add(className);
|
||||
});
|
||||
});
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.removeClass = function(classNameOrList) {
|
||||
var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList];
|
||||
this.forEach(function(el) {
|
||||
classList.forEach(function(classNames) {
|
||||
// Split space-separated class names
|
||||
var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; });
|
||||
classes.forEach(function(className) {
|
||||
el.classList.remove(className);
|
||||
});
|
||||
});
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.toggleClass = function(classNameOrList, force) {
|
||||
var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList];
|
||||
this.forEach(function(el) {
|
||||
classList.forEach(function(classNames) {
|
||||
// Split space-separated class names
|
||||
var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; });
|
||||
classes.forEach(function(className) {
|
||||
if (typeof force !== 'undefined') {
|
||||
el.classList.toggle(className, force);
|
||||
} else {
|
||||
el.classList.toggle(className);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.empty = function() {
|
||||
this.forEach(function(el) {
|
||||
el.innerHTML = '';
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.css = function(styles, optOrValue) {
|
||||
// If no arguments or first argument is a string (getter mode)
|
||||
if (arguments.length === 0 || (typeof styles === 'string' && arguments.length === 1)) {
|
||||
if (this.length === 0) return undefined;
|
||||
var el = this[0];
|
||||
if (typeof styles === 'string') {
|
||||
// Get computed style for a specific property
|
||||
return window.getComputedStyle(el)[styles];
|
||||
} else {
|
||||
// Return computed styles object (not commonly used)
|
||||
return window.getComputedStyle(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Setter mode
|
||||
this.forEach(function(el) {
|
||||
if (typeof styles === 'string' && arguments.length >= 2) {
|
||||
// Single property setter: .css('prop', 'value')
|
||||
el.style[styles] = optOrValue;
|
||||
} else if (typeof styles === 'object') {
|
||||
// Multiple properties setter: .css({prop1: 'value1', prop2: 'value2'})
|
||||
for (var key in styles) {
|
||||
el.style[key] = styles[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.each = function(callback) {
|
||||
this.forEach(function(el, index) {
|
||||
callback(el, index);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.append = function(child_or_html) {
|
||||
this.forEach(function(el) {
|
||||
if (typeof child_or_html === 'string' && el.insertAdjacentHTML) {
|
||||
if (child_or_html.includes('<script')) {
|
||||
const result = executeScripts(child_or_html);
|
||||
el.insertAdjacentHTML('beforeend', result.html);
|
||||
result.scripts.forEach(executeScript);
|
||||
} else {
|
||||
el.insertAdjacentHTML('beforeend', child_or_html);
|
||||
}
|
||||
} else if (el.appendChild) {
|
||||
if (typeof child_or_html === 'string') {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = child_or_html;
|
||||
el.appendChild(tempDiv.firstChild);
|
||||
} else {
|
||||
el.appendChild(child_or_html);
|
||||
}
|
||||
} else if(el.hasOwnProperty('innerHTML')) {
|
||||
el.innerHTML += child_or_html;
|
||||
}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
QueryWrapper.prototype.remove = function() {
|
||||
this.forEach(function(el) {
|
||||
el.parentNode.removeChild(el);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
function first(...args) {
|
||||
for (const arg of args) {
|
||||
if (arg !== undefined && arg !== null && arg !== '') {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
if (v < min) return min;
|
||||
if (v > max) return max;
|
||||
return v;
|
||||
}
|
||||
|
||||
function pick_entry_from_range(array, value) {
|
||||
if (!array) return {};
|
||||
let result = {};
|
||||
for (const [pv] of Object.entries(array)) {
|
||||
if (value >= pv.from && value <= pv.to) result = pv;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
$: $,
|
||||
EventEmitter: EventEmitter,
|
||||
QueryWrapper: QueryWrapper,
|
||||
first: first,
|
||||
clamp: clamp,
|
||||
pick_entry_from_range: pick_entry_from_range
|
||||
};
|
||||
|
||||
}));
|
||||
@@ -0,0 +1,176 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const format = value => Number(Number(value).toPrecision(6)).toString();
|
||||
const palette = [[68,1,84],[59,82,139],[33,145,140],[94,201,98],[253,231,37]];
|
||||
|
||||
window.ExoVisualization = function (container) {
|
||||
const root = container;
|
||||
let index = null, frameUrl = null, seriesUrl = null, frameAbort = null, seriesAbort = null;
|
||||
let frameToken = 0, seriesToken = 0, frame = null, chart = null, gl = null, texture = null, program = null;
|
||||
let raf = 0, observer = null, destroyed = false, mode = 'map', time = 0, level = 0, field = '', min = 0, max = 1;
|
||||
let rotation = 0, pitch = 0, drag = null, bounds = null, textureDirty = true, software = false;
|
||||
let composite = null, rgbFrames = null, rendering = 'field';
|
||||
const raster = document.createElement('canvas');
|
||||
const rasterContext = raster.getContext('2d');
|
||||
root.innerHTML = `<div class="viz-root"><div class="viz-controls"><label>View <select data-view><option value="field">Single field</option><option value="rgb">Temperature / rainfall / elevation</option></select></label><label>Field <select data-field></select></label><label>Time <input data-time type="range" min="0" max="0" value="0"></label><output data-time-label></output><label>Level <select data-level></select></label><button type="button" data-mode>Globe</button><button type="button" data-play aria-pressed="false">Play</button></div><div class="viz-view"><canvas class="viz-map" tabindex="0" aria-label="Spatial field map. Use arrow keys to inspect cells."></canvas><canvas class="viz-globe" tabindex="0" hidden aria-label="Spatial field globe. Drag or use arrow keys to rotate."></canvas><div class="viz-empty" role="status">No spatial data</div><div class="viz-tooltip" hidden></div></div><div class="viz-footer"><div class="viz-legend"><span data-min></span><div></div><span data-max></span></div><div class="viz-rgb-legend" hidden></div><output class="viz-inspector" data-inspector>Move over the field to inspect a value.</output><canvas class="viz-chart" aria-label="Global mean by time"></canvas></div></div>`;
|
||||
const $ = s => root.querySelector(s), map = $('.viz-map'), globe = $('.viz-globe'), chartCanvas = $('.viz-chart'), empty = $('.viz-empty'), tip = $('.viz-tooltip');
|
||||
const viewSelect = $('[data-view]'), fieldSelect = $('[data-field]'), levelSelect = $('[data-level]'), timeInput = $('[data-time]');
|
||||
const context = map.getContext('2d');
|
||||
$('.viz-legend div').style.background='linear-gradient(90deg,'+palette.map(c=>'rgb('+c.join(',')+')').join(',')+')';
|
||||
function currentField() { return (index && index.fields || []).find(x => x.name === field); }
|
||||
function isComposite() { return rendering === 'rgb' && Boolean(composite); }
|
||||
function showLegend() {
|
||||
$('.viz-legend').hidden=isComposite();$('.viz-rgb-legend').hidden=!isComposite();
|
||||
const legend=$('.viz-rgb-legend');legend.replaceChildren();
|
||||
if(!isComposite())return;
|
||||
composite.channels.forEach((channel,i)=>{const row=document.createElement('div'),name=document.createElement('span'),bar=document.createElement('i'),range=document.createElement('span');name.textContent=channel.label;range.textContent=`${format(channel.min)}–${format(channel.max)} ${channel.unit}`;bar.style.background=`linear-gradient(90deg,#000,${['#ff0000','#00ff00','#0000ff'][i]})`;row.append(name,bar,range);legend.append(row);});
|
||||
}
|
||||
function setStatus(text) { empty.textContent = text; empty.hidden = false; }
|
||||
function color(v) {
|
||||
if (!Number.isFinite(v)) return [0,0,0,0];
|
||||
const t = max === min ? .5 : Math.max(0, Math.min(1, (v-min)/(max-min)));
|
||||
const position=t*(palette.length-1),i=Math.min(palette.length-2,Math.floor(position)),fraction=position-i;
|
||||
return palette[i].map((value,c)=>value+(palette[i+1][c]-value)*fraction);
|
||||
}
|
||||
function sizeCanvas(canvas,limit=Infinity) { const r=canvas.getBoundingClientRect(),d=Math.min(window.devicePixelRatio||1,2),scale=Math.min(1,limit/Math.max(r.width*d,r.height*d)),w=Math.max(1,Math.round(r.width*d*scale)),h=Math.max(1,Math.round(r.height*d*scale));if(canvas.width!==w)canvas.width=w;if(canvas.height!==h)canvas.height=h;return d*scale; }
|
||||
function coords() { const lat=index&&index.lat||[], lon=index&&index.lon||[]; return {lat,lon}; }
|
||||
function nearest(a,v,period=0) { let best=0,dist=Infinity;for(let i=0;i<a.length;i++){let d=Math.abs(Number(a[i])-v);if(period){d%=period;d=Math.min(d,period-d);}if(d<dist){dist=d;best=i;}}return best; }
|
||||
function updateRaster() {
|
||||
if (!frame || !textureDirty) return;
|
||||
const rows=frame.length, cols=frame[0].length, height=Math.max(256,rows*2);
|
||||
const {lat,lon}=coords(), step=lon.length>1?Number(lon[1])-Number(lon[0]):360;
|
||||
bounds={latMin:-90,latMax:90,lonMin:Number(lon[0])-step/2,lonMax:Number(lon[0])+360-step/2,rows,cols};
|
||||
raster.width=cols; raster.height=height;
|
||||
const image=rasterContext.createImageData(cols,height),rgb=isComposite(),nativeColors=rgb?new Uint8ClampedArray(rows*cols*4):null;
|
||||
if(rgb)for(let row=0;row<rows;row++)for(let x=0;x<cols;x++){
|
||||
const p=(row*cols+x)*4,red=window.ExoComposite.normalise(rgbFrames[0][row][x],composite.channels[0]),green=window.ExoComposite.normalise(rgbFrames[1][row][x],composite.channels[1]),blue=window.ExoComposite.normalise(rgbFrames[2][row][x],composite.channels[2]);
|
||||
nativeColors[p]=red*255;nativeColors[p+1]=green*255;nativeColors[p+2]=blue*255;nativeColors[p+3]=Number.isFinite(red)&&Number.isFinite(green)&&Number.isFinite(blue)?255:0;
|
||||
}
|
||||
for(let y=0;y<height;y++) {
|
||||
const row=nearest(lat,90-(y+.5)/height*180);
|
||||
if(rgb){image.data.set(nativeColors.subarray(row*cols*4,(row+1)*cols*4),y*cols*4);continue;}
|
||||
for(let x=0;x<cols;x++) {
|
||||
const c=color(Number(frame[row][x])),p=(y*cols+x)*4;
|
||||
image.data[p]=c[0];image.data[p+1]=c[1];image.data[p+2]=c[2];image.data[p+3]=c.length===4?c[3]:255;
|
||||
}
|
||||
}
|
||||
rasterContext.putImageData(image,0,0);
|
||||
}
|
||||
function drawMap() {
|
||||
if (destroyed || !frame || mode !== 'map') return;
|
||||
updateRaster(); sizeCanvas(map); context.clearRect(0,0,map.width,map.height);
|
||||
context.imageSmoothingEnabled=false; context.drawImage(raster,0,0,map.width,map.height);
|
||||
}
|
||||
function initGL() {
|
||||
if(gl)return true;
|
||||
gl=globe.getContext('webgl',{alpha:false,antialias:true})||globe.getContext('experimental-webgl');
|
||||
if(!gl)return false;
|
||||
const vs='attribute vec2 p; varying vec2 uv; void main(){uv=(p+1.0)*0.5;gl_Position=vec4(p,0.,1.);}';
|
||||
const fs='precision mediump float; varying vec2 uv; uniform sampler2D tex; uniform float aspect; uniform float rot; uniform float pitch; uniform float offset; uniform float shade; void main(){vec2 q=(uv*2.0-1.0);q.x*=max(aspect,1.);q.y/=min(aspect,1.);float r=length(q);if(r>1.){gl_FragColor=vec4(0.035,0.055,0.08,1.);return;}float z=sqrt(max(0.,1.-r*r));vec3 n=vec3(q.x,q.y*cos(pitch)+z*sin(pitch),z*cos(pitch)-q.y*sin(pitch));float lon=atan(n.x,n.z)+rot;float lat=asin(clamp(n.y,-1.,1.));vec2 t=vec2(fract(lon/6.2831853+offset),0.5-lat/3.14159265);vec4 c=texture2D(tex,t);float light=0.35+0.65*max(0.,dot(normalize(vec3(q,z)),normalize(vec3(-.4,.5,1.))));gl_FragColor=vec4(mix(vec3(.035,.055,.08),c.rgb*mix(1.,light,shade),c.a),1.);}';
|
||||
function shader(type,src){const s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))return null;return s;}
|
||||
const a=shader(gl.VERTEX_SHADER,vs),b=shader(gl.FRAGMENT_SHADER,fs);if(!a||!b)return false;
|
||||
program=gl.createProgram();gl.attachShader(program,a);gl.attachShader(program,b);gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))return false;
|
||||
const buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);gl.useProgram(program);const loc=gl.getAttribLocation(program,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);texture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,texture);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);return true;
|
||||
}
|
||||
function drawGlobe() {
|
||||
if(destroyed||mode!=='globe'||!frame)return;
|
||||
if(software||!initGL()){software=true;globe.hidden=true;map.hidden=false;drawSoftwareGlobe();return;}
|
||||
updateRaster(); sizeCanvas(globe,640);
|
||||
gl.viewport(0,0,globe.width,globe.height);gl.bindTexture(gl.TEXTURE_2D,texture);
|
||||
if(textureDirty){gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,raster.width,raster.height,0,gl.RGBA,gl.UNSIGNED_BYTE,rasterContext.getImageData(0,0,raster.width,raster.height).data);textureDirty=false;}
|
||||
gl.useProgram(program);gl.uniform1f(gl.getUniformLocation(program,'shade'),isComposite()?0:1);gl.uniform1f(gl.getUniformLocation(program,'aspect'),globe.width/globe.height);gl.uniform1f(gl.getUniformLocation(program,'rot'),rotation);gl.uniform1f(gl.getUniformLocation(program,'pitch'),pitch);gl.uniform1f(gl.getUniformLocation(program,'offset'),.5/raster.width-Number(index.lon[0])/360);gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
||||
}
|
||||
function drawSoftwareGlobe(){
|
||||
updateRaster();sizeCanvas(map,320);
|
||||
const w=map.width,h=map.height,aspect=w/h,image=context.createImageData(w,h),source=rasterContext.getImageData(0,0,raster.width,raster.height).data,cp=Math.cos(pitch),sp=Math.sin(pitch),offset=.5/raster.width-Number(index.lon[0])/360,lightLength=Math.sqrt(1.41);
|
||||
for(let y=0;y<h;y++)for(let x=0;x<w;x++){
|
||||
const p=(y*w+x)*4,qx=((x+.5)/w*2-1)*Math.max(aspect,1),qy=(1-(y+.5)/h*2)/Math.min(aspect,1),squared=qx*qx+qy*qy;
|
||||
image.data[p]=9;image.data[p+1]=14;image.data[p+2]=20;image.data[p+3]=255;
|
||||
if(squared>1)continue;
|
||||
const z=Math.sqrt(1-squared),ny=qy*cp+z*sp,nz=z*cp-qy*sp,latitude=Math.asin(Math.max(-1,Math.min(1,ny))),longitude=Math.atan2(qx,nz)+rotation,u=((longitude/(2*Math.PI)+offset)%1+1)%1,v=.5-latitude/Math.PI,si=(Math.min(raster.height-1,Math.floor(v*raster.height))*raster.width+Math.min(raster.width-1,Math.floor(u*raster.width)))*4,light=isComposite()?1:.35+.65*Math.max(0,(-.4*qx+.5*qy+z)/lightLength),alpha=source[si+3]/255;
|
||||
for(let channel=0;channel<3;channel++)image.data[p+channel]=image.data[p+channel]*(1-alpha)+source[si+channel]*light*alpha;
|
||||
}
|
||||
context.putImageData(image,0,0);textureDirty=false;
|
||||
}
|
||||
function redraw(){if(mode==='map')drawMap();else drawGlobe();}
|
||||
function drawChart(values) {
|
||||
const c=chartCanvas.getContext('2d'),d=sizeCanvas(chartCanvas),w=chartCanvas.width,h=chartCanvas.height;
|
||||
c.clearRect(0,0,w,h);if(!values?.mean)return;
|
||||
const valid=values.mean.filter(v=>v!==null&&Number.isFinite(Number(v))).map(Number);
|
||||
if(!valid.length)return;
|
||||
const lo=Math.min(...valid),hi=Math.max(...valid),left=60*d,right=w-12*d,top=18*d,bottom=h-20*d;
|
||||
const times=values.time||index.time,start=Number(times[0]),end=Number(times.at(-1));
|
||||
c.fillStyle='#a8b7b9';c.font=`${10*d}px sans-serif`;
|
||||
c.fillText(`Global mean ${isComposite()?'temperature ':''}${values.unit||''}`,left,12*d);
|
||||
c.fillText(hi.toPrecision(4),0,top+8*d);c.fillText(lo.toPrecision(4),0,bottom);
|
||||
c.fillText(`${format(start)} ${values.time_unit||''}`,left,h-3*d);
|
||||
c.textAlign='right';c.fillText(format(end),right,h-3*d);c.textAlign='left';
|
||||
c.strokeStyle='#65b7d8';c.lineWidth=2*d;c.beginPath();let gap=true;
|
||||
values.mean.forEach((v,i)=>{if(v===null||!Number.isFinite(Number(v))){gap=true;return;}
|
||||
const x=left+(Number(times[i])-start)/(end-start||1)*(right-left),y=bottom-(Number(v)-lo)/(hi-lo||1)*(bottom-top);
|
||||
if(gap)c.moveTo(x,y);else c.lineTo(x,y);gap=false;
|
||||
if(values.mean.length===1)c.rect(x-2*d,y-2*d,4*d,4*d);
|
||||
});c.stroke();
|
||||
}
|
||||
async function readPlane(meta,signal) {
|
||||
const response=await fetch(frameUrl(meta.name,Math.min(time,meta.shape[0]-1),isComposite()?0:level),{signal,credentials:'same-origin'});
|
||||
if(!response.ok)throw Error(`Frame request failed (${response.status})`);
|
||||
const raw=new Float32Array(await response.arrayBuffer()),rows=meta.shape[2],cols=meta.shape[3];
|
||||
if(!rows||!cols||raw.length!==rows*cols)throw Error('Frame dimensions do not match the dataset index');
|
||||
return Array.from({length:rows},(_,r)=>raw.subarray(r*cols,(r+1)*cols));
|
||||
}
|
||||
async function loadFrame() {
|
||||
if(!index||!field||!frameUrl)return;
|
||||
const token=++frameToken;if(frameAbort)frameAbort.abort();frameAbort=new AbortController();frame=null;rgbFrames=null;tip.hidden=true;
|
||||
$('[data-inspector]').textContent='Loading field…';setStatus('Loading field…');
|
||||
const rgb=isComposite(),metas=rgb?composite.channels.map(channel=>index.fields.find(meta=>meta.name===channel.name)):[currentField()];
|
||||
try{
|
||||
const planes=await Promise.all(metas.map(meta=>readPlane(meta,frameAbort.signal)));
|
||||
if(token!==frameToken||destroyed)return;
|
||||
frame=planes[0];if(rgb)rgbFrames=planes;textureDirty=true;
|
||||
const fieldMeta=metas[0];min=fieldMeta.min==null?NaN:Number(fieldMeta.min);max=fieldMeta.max==null?NaN:Number(fieldMeta.max);
|
||||
$('[data-min]').textContent=Number.isFinite(min)?min.toPrecision(4):'';$('[data-max]').textContent=Number.isFinite(max)?max.toPrecision(4):'';
|
||||
empty.hidden=true;redraw();if(inspected)inspectCell(...inspected);else $('[data-inspector]').textContent='Tap or point at the field to inspect a value.';
|
||||
}catch(e){if(e.name!=='AbortError'&&token===frameToken&&!destroyed){frameAbort.abort();const message=e.message||'Could not load field';setStatus(message);$('[data-inspector]').textContent=message;}}
|
||||
}
|
||||
async function loadSeries(){if(!index||!field||!seriesUrl)return;const token=++seriesToken;if(seriesAbort)seriesAbort.abort();seriesAbort=new AbortController();chart=null;drawChart(null);try{const response=await fetch(seriesUrl(field,level),{signal:seriesAbort.signal,credentials:'same-origin'});if(!response.ok)throw Error(`Series request failed (${response.status})`);const data=await response.json();if(token===seriesToken&&!destroyed){if(!data.ok)throw Error(data.error||'Could not load global means');chart=data.data;drawChart(chart);}}catch(e){if(e.name!=='AbortError'&&token===seriesToken)drawChart(null);} }
|
||||
function changed(){
|
||||
const previous=rendering;rendering=viewSelect.value;const viewChanged=previous!==rendering;
|
||||
const next=isComposite()?composite.channels[0].name:fieldSelect.value,old=field,oldLevel=level;field=next;
|
||||
fieldSelect.closest('label').hidden=isComposite();showLegend();
|
||||
if(next!==old||!levelSelect.options.length){
|
||||
levelSelect.innerHTML='';const count=currentField()?.shape[1]||1;
|
||||
for(let i=0;i<count;i++){const option=document.createElement('option');option.value=i;option.textContent=String(index.levels?.[i]??i);levelSelect.append(option);}
|
||||
levelSelect.closest('label').hidden=count===1||isComposite();
|
||||
}
|
||||
level=Number(levelSelect.value)||0;time=Number(timeInput.value)||0;
|
||||
const times=index?.time||[];$('[data-time-label]').textContent=times[time]==null?String(time):`${format(times[time])} ${index.time_unit||''}`;
|
||||
loadFrame();if(next!==old||level!==oldLevel||viewChanged||!chart)loadSeries();
|
||||
}
|
||||
viewSelect.addEventListener('change',changed);fieldSelect.addEventListener('change',changed);levelSelect.addEventListener('change',changed);timeInput.addEventListener('input',changed);
|
||||
$('[data-mode]').addEventListener('click',()=>{mode=mode==='map'?'globe':'map';map.hidden=mode!=='map'&&!software;globe.hidden=mode!=='globe'||software;$('[data-mode]').textContent=mode==='map'?'Globe':'Map';map.setAttribute('aria-label',mode==='map'?'Spatial field map. Use arrow keys to inspect cells.':'Spatial field globe. Drag or use arrow keys to rotate.');redraw();});
|
||||
let playTimer=0;const play=$('[data-play]');function stopPlayback(){if(playTimer)clearInterval(playTimer);playTimer=0;play.textContent='Play';play.setAttribute('aria-pressed','false');}play.addEventListener('click',()=>{if(playTimer){stopPlayback();return;}play.textContent='Pause';play.setAttribute('aria-pressed','true');playTimer=setInterval(()=>{if(document.hidden)return;const n=Number(timeInput.max);timeInput.value=String((Number(timeInput.value)+1)%(n+1));changed();},600);});
|
||||
let inspected=null;
|
||||
function inspectCell(ri,ci){if(!frame)return;inspected=[ri,ci];const value=Number(frame[ri][ci]),{lat,lon}=coords(),position=`${Number(lat[ri]).toFixed(2)}°, ${Number(lon[ci]).toFixed(2)}°`;
|
||||
const text=isComposite()?position+'\n'+composite.channels.map((channel,i)=>{const v=Number(rgbFrames[i][ri][ci])*channel.factor;return`${channel.label}: ${Number.isFinite(v)?v.toPrecision(5)+' '+channel.unit:'missing'}`;}).join('\n'):`${position} · ${Number.isFinite(value)?value.toPrecision(5)+(currentField().unit?' '+currentField().unit:''):'missing'}`;tip.textContent=text;$('[data-inspector]').textContent=text;}
|
||||
function inspectPointer(event,canvas,latitude,longitude){const {lat,lon}=coords();inspectCell(nearest(lat,latitude),nearest(lon,longitude,360));const r=canvas.getBoundingClientRect();tip.hidden=false;tip.style.left=Math.max(0,Math.min(r.width-tip.offsetWidth-8,event.clientX-r.left+12))+'px';tip.style.top=Math.max(0,Math.min(r.height-tip.offsetHeight-8,event.clientY-r.top+12))+'px';}
|
||||
function mapPointer(event){if(mode==='globe'){globePointer(event,map);return;}if(!frame||!bounds)return;const r=map.getBoundingClientRect(),x=(event.clientX-r.left)/r.width,y=(event.clientY-r.top)/r.height;inspectPointer(event,map,90-y*180,bounds.lonMin+x*360);}
|
||||
map.addEventListener('pointermove',mapPointer);
|
||||
map.addEventListener('pointerdown',event=>{if(mode==='globe')startDrag(event,map);});
|
||||
map.addEventListener('pointerup',event=>{drag=null;mapPointer(event);});map.addEventListener('pointercancel',()=>{drag=null;});
|
||||
map.addEventListener('pointerleave',()=>{tip.hidden=true;});
|
||||
map.addEventListener('keydown',event=>{if(mode==='globe'){rotateKeys(event);return;}if(!frame)return;let [ri,ci]=inspected||[Math.floor(frame.length/2),Math.floor(frame[0].length/2)];if(event.key==='ArrowUp')ri--;else if(event.key==='ArrowDown')ri++;else if(event.key==='ArrowLeft')ci--;else if(event.key==='ArrowRight')ci++;else return;event.preventDefault();inspectCell(Math.max(0,Math.min(frame.length-1,ri)),(ci+frame[0].length)%frame[0].length);});
|
||||
function startDrag(event,canvas){drag={x:event.clientX,y:event.clientY,rot:rotation,pitch};canvas.setPointerCapture(event.pointerId);tip.hidden=true;}
|
||||
function globePointer(event,canvas){if(drag){rotation=drag.rot+(event.clientX-drag.x)/Math.max(1,canvas.clientWidth)*Math.PI*2;pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,drag.pitch+(event.clientY-drag.y)/Math.max(1,canvas.clientHeight)*Math.PI));if(!raf)raf=requestAnimationFrame(()=>{raf=0;redraw();});return;}if(!frame||mode!=='globe')return;const r=canvas.getBoundingClientRect(),x=((event.clientX-r.left)/r.width*2-1)*Math.max(1,r.width/r.height),y=(1-(event.clientY-r.top)/r.height*2)/Math.min(1,r.width/r.height),squared=x*x+y*y;if(squared>1){tip.hidden=true;return;}const z=Math.sqrt(1-squared),ny=y*Math.cos(pitch)+z*Math.sin(pitch),nz=z*Math.cos(pitch)-y*Math.sin(pitch);inspectPointer(event,canvas,Math.asin(Math.max(-1,Math.min(1,ny)))*180/Math.PI,(Math.atan2(x,nz)+rotation)*180/Math.PI);}
|
||||
globe.addEventListener('pointerdown',event=>startDrag(event,globe));globe.addEventListener('pointermove',event=>globePointer(event,globe));
|
||||
globe.addEventListener('pointerup',event=>{drag=null;globePointer(event,globe);});globe.addEventListener('pointercancel',()=>{drag=null;});globe.addEventListener('pointerleave',()=>{tip.hidden=true;});
|
||||
function rotateKeys(event){if(event.key==='ArrowLeft')rotation-=.1;else if(event.key==='ArrowRight')rotation+=.1;else if(event.key==='ArrowUp')pitch+=.1;else if(event.key==='ArrowDown')pitch-=.1;else if(event.key==='Home'){rotation=0;pitch=0;}else return;event.preventDefault();pitch=Math.max(-Math.PI/2,Math.min(Math.PI/2,pitch));redraw();}
|
||||
globe.addEventListener('keydown',rotateKeys);
|
||||
globe.addEventListener('webglcontextlost',event=>{event.preventDefault();gl=null;program=null;texture=null;textureDirty=true;software=true;redraw();});
|
||||
globe.addEventListener('webglcontextrestored',()=>{software=false;textureDirty=true;redraw();});
|
||||
const visibility=()=>{if(!document.hidden)redraw();};
|
||||
observer=new ResizeObserver(()=>{if(!document.hidden){redraw();drawChart(chart);}});observer.observe(root);document.addEventListener('visibilitychange',visibility);
|
||||
return { setDataset(next,frameFn,seriesFn,options={}){stopPlayback();inspected=null;tip.hidden=true;$('[data-inspector]').textContent='Point at the map to inspect a value.';index=next||null;composite=window.ExoComposite?.create(index,options.gravity)||null;viewSelect.querySelector('[value="rgb"]').disabled=!composite;viewSelect.closest('label').hidden=!composite;if(!composite)viewSelect.value='field';rendering=viewSelect.value;rgbFrames=null;frameUrl=frameFn;seriesUrl=seriesFn;frame=null;chart=null;textureDirty=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();frameToken++;seriesToken++;const previousField=fieldSelect.value,previousLevel=level;fieldSelect.innerHTML='';levelSelect.innerHTML='';const fields=index&&index.fields||[];for(const f of fields){const o=document.createElement('option');o.value=f.name;o.textContent=(f.label||f.name).replaceAll('_',' ')+(f.unit?' ('+f.unit+')':'');fieldSelect.appendChild(o);}field=(fields.find(f=>f.name===previousField)||fields.find(f=>f.name==='ts')||fields.find(f=>f.name==='elevation')||fields[0])?.name||'';fieldSelect.value=field;const count=currentField()?.shape[1]||0;levelSelect.closest('label').hidden=count<=1;for(let i=0;i<count;i++){const o=document.createElement('option');o.value=String(i);o.textContent=(index.levels&&index.levels[i]!=null)?String(index.levels[i]):String(i);levelSelect.appendChild(o);}const times=index&&index.time||[];timeInput.disabled=times.length<=1;$('[data-play]').disabled=times.length<=1;timeInput.max=String(Math.max(0,times.length-1));timeInput.value='0';time=0;level=Math.min(previousLevel,Math.max(0,count-1));levelSelect.value=String(level);empty.hidden=fields.length>0;setStatus(fields.length?'Loading field…':'No spatial data');if(fields.length)changed();else{drawChart(null);}}, destroy(){destroyed=true;if(frameAbort)frameAbort.abort();if(seriesAbort)seriesAbort.abort();if(observer)observer.disconnect();if(raf)cancelAnimationFrame(raf);stopPlayback();document.removeEventListener('visibilitychange',visibility);if(gl){gl.deleteTexture(texture);gl.deleteProgram(program);}root.innerHTML='';} };
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
(function startWorkbench() {
|
||||
'use strict';
|
||||
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',startWorkbench,{once:true});return;}
|
||||
const boot=window.EXOPLASIM||{},api=boot.api||{},$=(s,r=document)=>r.querySelector(s);
|
||||
const state={schema:null,experiments:[],jobs:[],experiment:null,draftName:'',spec:null,dirty:false,job:null,logOffset:0,logTimer:null,pollTimer:null,visual:null,saveVersion:0,editVersion:0,saving:false,deleting:false,selection:0,pollBusy:false,resultLoaded:'',results:null,mapEditor:null,mapDirty:false,invalidFields:new Map()};
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const notice=(t,error=false)=>{const e=$('#workbench-notice');e.textContent=t;e.classList.toggle('error-text',error);e.classList.add('visible');clearTimeout(notice.timer);notice.timer=setTimeout(()=>e.classList.remove('visible'),4500);};
|
||||
const endpoint=n=>api[n]||'';
|
||||
function apiUrl(name,values={}){const q=Object.entries(values).filter(([,v])=>v!==undefined&&v!==null&&v!=='').map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&');return endpoint(name)+(q?'&'+q:'');}
|
||||
async function request(name,o={}){const u=apiUrl(name,o.query);const h=new Headers(o.headers||{});if(o.body!==undefined){h.set('Content-Type','application/json');}const r=await fetch(u,{method:o.method||'GET',headers:h,body:o.body===undefined?undefined:JSON.stringify(o.body),signal:o.signal,credentials:'same-origin'});if(['frame','map'].includes(name)&&r.ok)return r.arrayBuffer();if(name==='download')return r;const p=await r.json().catch(()=>({ok:false,error:`Request failed (${r.status})`}));if(!r.ok||!p.ok){const e=new Error(p.error||`Request failed (${r.status})`);e.errors=p.errors||[];e.status=r.status;throw e;}return p.data;}
|
||||
function setDirty(v=true){if(v)state.editVersion++;state.dirty=v;$('#save-state').textContent=v?'Unsaved changes':'Saved';$('#save-state').classList.toggle('dirty',v);}
|
||||
function freshSpec(){const s=state.schema||{};return{model:structuredClone(s.model_defaults||{}),parameters:{},run:structuredClone(s.run_defaults||{})};}
|
||||
function setExperiment(e){state.experiment=e;state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};state.invalidFields.clear();setDirty(false);renderAll();}
|
||||
function renderExperiments(){const t=$('#experiment-search').value.toLowerCase();$('#experiment-list').innerHTML=state.experiments.map(e=>`<li class="experiment-item" ${e.name.toLowerCase().includes(t)?'':'hidden'}><button class="experiment-select" type="button" data-experiment="${esc(e.id)}" aria-current="${state.experiment?.id===e.id}">${esc(e.name)}<small>${esc((e.updated_at||'').slice(0,10))}</small></button></li>`).join('');}
|
||||
function renderConstructor(){const m=state.spec.model,r=state.spec.run,s=$('#model-resolution');s.innerHTML='';const choices=state.schema.model?.resolution?.choices||state.schema.model_metadata?.resolution?.choices||Object.keys(state.schema.model_metadata||{});for(const c of choices){const o=document.createElement('option');o.value=c;o.textContent=c;s.append(o);}s.value=m.resolution||'';$('#model-layers').value=m.layers??'';$('#model-ncpus').value=m.ncpus??'';$('#run-cycles').value=r.cycles??'';$('#run-steps').value=r.steps??'';$('#experiment-name').value=state.draftName;$('#active-experiment-title').textContent=state.draftName||'New experiment';}
|
||||
function inputFor(f,v){const k=esc(f.name),disabled=f.editable===false?'disabled':'';let h;if(f.type==='boolean')h=`<select id="param-${k}" data-param="${k}" ${disabled}><option value="true" ${v===true?'selected':''}>True</option><option value="false" ${v===false?'selected':''}>False</option></select>`;else if(f.type==='object'||f.type==='array')h=`<textarea id="param-${k}" data-param="${k}" ${disabled}>${esc(v==null?'':typeof v==='string'?v:JSON.stringify(v,null,2))}</textarea>`;else if(f.choices)h=`<select id="param-${k}" data-param="${k}" ${disabled}>${f.choices.map(c=>`<option value="${esc(c)}" ${v===c?'selected':''}>${esc(c)}</option>`).join('')}</select>`;else h=`<input id="param-${k}" data-param="${k}" ${disabled} type="${f.type==='number'||f.type==='integer'?'number':'text'}" step="${f.type==='integer'?'1':esc(f.step||'any')}" value="${v==null?'':esc(v)}" ${f.nullable?'':'required'}>`;if(f.nullable)h+=`<label class="null-toggle"><input type="checkbox" data-null="${k}" ${v===null?'checked':''}> Default</label>`;return h;}
|
||||
function renderParameters(){const fs=(state.schema.fields||[]).filter(f=>f.editable),term=$('#parameter-search').value.toLowerCase(),groups=new Map();for(const f of fs){const g=f.group||'Other';if(!groups.has(g))groups.set(g,[]);groups.get(g).push(f);}let total=0,shown=0,h='';for(const[g,items]of groups){const before=shown,rows=items.map(f=>{const v=Object.hasOwn(state.spec.parameters,f.name)?state.spec.parameters[f.name]:f.default,show=`${f.name} ${f.label||''} ${f.description||''} ${g}`.toLowerCase().includes(term);total++;if(show)shown++;return`<div class="parameter-row" ${show?'':'hidden'}><label for="param-${esc(f.name)}"><abbr title="${esc(f.description||f.label||f.name)}">${esc(f.label||f.name)}</abbr></label><div>${inputFor(f,state.invalidFields.has(f.name)?state.invalidFields.get(f.name):v)}${f.unit?`<small>${esc(f.unit)}</small>`:''}</div></div>`;}).join('');if(shown>before)h+=`<section class="parameter-group"><div class="group-title">${esc(g)}</div>${rows}</section>`;}$('#parameter-fields').innerHTML=h;$('#parameter-count').textContent=`${shown} / ${total}`;}
|
||||
function actions(){document.querySelectorAll('[data-action="save"],[data-action="save-run"]').forEach(b=>b.disabled=state.saving||state.deleting||!state.schema);const has=!!state.experiment;document.querySelectorAll('[data-action="clone"],[data-action="delete"],[data-action="export"]').forEach(b=>b.disabled=!has||(b.dataset.action==='delete'&&state.deleting));document.querySelector('[data-action="cancel"]').disabled=!state.job||!['queued','running'].includes(state.job.status);document.querySelector('[data-action="retry"]').disabled=!state.job||!['failed','cancelled','interrupted'].includes(state.job.status);const failure=$('#job-error'),message=state.job?.error||'';if(failure.textContent!==message)failure.textContent=message;failure.hidden=!message;}
|
||||
function renderAll(){renderExperiments();renderConstructor();renderParameters();renderMaps();actions();}
|
||||
function renderMaps(){$('.surface-map-controls').hidden=!state.schema.map_fields;const keys=Object.keys(state.spec.maps||{});$('#surface-map-status').textContent=keys.map(key=>state.schema.map_fields?.[key]?.label||key).join(' · ');$('[data-action="clear-maps"]').disabled=!keys.length;}
|
||||
function mapValues(buffer){if(buffer.byteLength%4)throw Error('Map byte count is invalid.');const view=new DataView(buffer),values=new Float32Array(buffer.byteLength/4);for(let i=0;i<values.length;i++)values[i]=view.getFloat32(i*4,true);return values;}
|
||||
async function openMaps(){const owner=state.spec,resolution=owner.model.resolution,fields=state.schema.map_fields,grid=state.schema.map_grids?.[resolution];if(!fields||!grid){notice('Map catalog is not available. Refresh the workbench after setup.',true);return;}try{const data=await request('maps',{query:{resolution}}),versions=data.maps;let terrain=null;const job=state.jobs.find(j=>j.kind==='planet'&&j.status==='completed'&&j.spec.model.resolution===resolution&&owner.surface&&Object.keys(state.schema.surface).every(k=>j.spec.surface?.[k]===owner.surface[k]));if(job){const result=await request('results',{query:{id:job.id}});terrain={job:job.id,index:result.dataset||result.index};}if(owner!==state.spec)return;const dialog=$('#map-editor-dialog');if(state.mapEditor)state.mapEditor.destroy();state.mapEditor=new window.ExoMapEditor($('#map-editor'),{
|
||||
fields,onDirty:dirty=>{state.mapDirty=dirty;}, sources:async field=>{const saved=versions.filter(v=>v.field===field),entries=saved.map(v=>({id:v.id,name:v.name}));if(owner.maps?.[field])entries.sort((a,b)=>(b.id===owner.maps[field])-(a.id===owner.maps[field]));if(terrain&&['height','landmask'].includes(field)){const entry={id:'terrain',name:'Generated terrain'};owner.maps?.[field]?entries.push(entry):entries.unshift(entry);}entries.push({id:'blank',name:'Uniform map'});return entries;},
|
||||
load:async(field,id)=>{if(id==='blank'){const values=new Float32Array(grid.lat.length*grid.lon.length);values.fill(fields[field].default??0);return{values,grid,name:fields[field].label};}if(id==='terrain'){const buffer=await request('frame',{query:{id:terrain.job,dataset:terrain.index.id,field:field==='height'?'elevation':'landmask',time:0,level:0}});return{values:mapValues(buffer),grid:{lat:terrain.index.lat,lon:terrain.index.lon},name:`${state.draftName} ${fields[field].label}`};}return{values:mapValues(await request('map',{query:{id}})),grid,name:versions.find(v=>v.id===id).name};},
|
||||
save:async(field,name,values)=>{const buffer=new ArrayBuffer(values.length*4),view=new DataView(buffer);for(let i=0;i<values.length;i++)view.setFloat32(i*4,values[i],true);const response=await fetch(apiUrl('maps',{field,resolution,name}),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream'},body:buffer});const result=await response.json();if(!response.ok||!result.ok)throw Error(result.error||'Map save failed.');versions.push(result.data);return result.data;},
|
||||
onSaved:(field,saved)=>{if(owner!==state.spec)return;state.spec.maps={...state.spec.maps,[field]:saved.id};setDirty();renderMaps();}
|
||||
});dialog.showModal();await state.mapEditor.start();}catch(error){notice(error.message,true);}}
|
||||
function closeMaps(){if(state.mapEditor?.canClose()){state.mapDirty=false;state.mapEditor.destroy();state.mapEditor=null;$('#map-editor-dialog').close();}}
|
||||
function showErrors(e){const x=e.errors||[];$('#editor-errors').textContent=Array.isArray(x)?x.map(v=>typeof v==='string'?v:`${v.field||''} ${v.message||v.error||''}`).join('\n'):JSON.stringify(x);}
|
||||
async function loadExperiments(){const d=await request('experiments');state.experiments=d.experiments||d.items||d||[];renderExperiments();}
|
||||
async function loadJobs(){const d=await request('jobs');state.jobs=d.jobs||d.items||d||[];renderJobs();}
|
||||
function receivedJob(job){const position=state.jobs.findIndex(item=>item.id===job.id);if(position<0)state.jobs.unshift(job);else state.jobs[position]=job;renderJobs();}
|
||||
function renderJobs(){const markup=state.jobs.map(j=>`<li class="job-item"><button class="job-select" data-job="${esc(j.id)}" type="button" aria-current="${state.job?.id===j.id}"><span class="job-status ${esc(j.status)}">${esc(j.status)}</span><small>${esc(j.name)} · ${esc(j.phase||'queued')}</small>${['running','queued'].includes(j.status)?`<span class="progress-track"><span style="width:${Math.max(0,Math.min(100,Number(j.progress)||0))}%"></span></span>`:''}<small>${j.spec?.run?.steps==null?'Full-year cycle':`${esc(j.spec.run.steps)} steps per cycle`}</small></button></li>`).join('');if(markup!==state.jobsMarkup){const focused=document.activeElement.closest('[data-job]')?.dataset.job;$('#job-list').innerHTML=markup;state.jobsMarkup=markup;if(focused)$('#job-list').querySelector(`[data-job="${focused}"]`)?.focus({preventScroll:true});}}
|
||||
function clearVisualization(){if(state.visual){state.visual.destroy();state.visual=null;}$('#visualization').innerHTML=`<div class="empty-results">${state.job&&['completed','failed','cancelled','running'].includes(state.job.status)?'Loading spatial output…':'No spatial output yet.'}</div>`;$('#visualization-downloads').innerHTML='';}
|
||||
async function loadLog(reset=false,token=state.selection){if(!state.job)return;if(reset){state.logOffset=0;$('#job-log').textContent='';}try{const d=await request('log',{query:{id:state.job.id,offset:state.logOffset}});if(token!==state.selection)return;const t=d.text??d.log??'';$('#job-log').textContent=($('#job-log').textContent+t).slice(-65536);state.logOffset=d.next_offset??state.logOffset+new TextEncoder().encode(t).length;$('#log-cursor').textContent=t?`+${state.logOffset} bytes`:'';}catch(e){if(token===state.selection)$('#log-cursor').textContent=e.message;}}
|
||||
function mountVisualization(d){
|
||||
const c=$('#visualization');if(state.visual)state.visual.destroy();if(!window.ExoVisualization){c.textContent='Visualization module is not available.';return;}
|
||||
const visual=state.visual=new window.ExoVisualization(c),jobId=state.job.id,gravity=state.job.spec?.parameters?.gravity??state.schema?.fields.find(meta=>meta.name==='gravity')?.implicit_default;
|
||||
let datasetRequest=0,downloadDataset='';const ds=d.datasets||[],initial=d.dataset||d.index||d,toolbar=$('#visualization-downloads');
|
||||
const select=document.createElement('select');select.className='dataset-select';select.setAttribute('aria-label','Dataset');
|
||||
for(const x of ds){const o=document.createElement('option');o.value=x.id;o.textContent=x.label||x.id;o.selected=x.id===initial.id;select.append(o);}if(ds.length>1)toolbar.append(select);
|
||||
const menu=document.createElement('select'),link=document.createElement('a');menu.className='dataset-select';menu.setAttribute('aria-label','Download file');link.textContent='Download';toolbar.append(menu,link);
|
||||
const update=()=>{link.href=apiUrl('download',{id:jobId,artifact:menu.value});};menu.addEventListener('change',update);
|
||||
function downloads(links,index){
|
||||
const preferred=menu.value?menu.value.replace(':'+downloadDataset,':'+index.id):'';downloadDataset=index.id;menu.replaceChildren();
|
||||
for(const artifact of links){const option=document.createElement('option');option.value=artifact.id;option.textContent=artifact.label||artifact.id;menu.append(option);}
|
||||
menu.hidden=link.hidden=!links.length;if(!links.length)return;
|
||||
menu.value=(links.find(artifact=>artifact.id===preferred)||links.find(artifact=>artifact.id==='raw:'+index.id||artifact.id.startsWith('raw:'+index.id+':'))||links[0]).id;update();
|
||||
}
|
||||
const use=(index,links)=>{visual.setDataset(index,(f,t,l)=>apiUrl('frame',{id:jobId,dataset:index.id,field:f,time:t,level:l}),(f,l)=>apiUrl('series',{id:jobId,dataset:index.id,field:f,level:l}),{gravity});downloads(links,index);};use(initial,d.downloads||[]);
|
||||
select.addEventListener('change',async()=>{const current=++datasetRequest;try{const x=await request('results',{query:{id:jobId,dataset:select.value}});if(visual!==state.visual||current!==datasetRequest)return;use(x.dataset||x.index||x,x.downloads||[]);}catch(e){if(visual===state.visual&¤t===datasetRequest)notice(e.message,true);}});
|
||||
}
|
||||
async function loadResult(token=state.selection){if(!state.job||!['completed','failed','cancelled','running'].includes(state.job.status)||state.resultLoaded===`${state.job.id}:${state.job.completed_cycles||0}`)return;const id=state.job.id,cycle=state.job.completed_cycles||0;try{const d=await request('results',{query:{id}});if(token!==state.selection||id!==state.job?.id)return;state.resultLoaded=`${id}:${cycle}`;mountVisualization(d);}catch(e){if(token===state.selection){state.resultLoaded=`${id}:${cycle}`;$('#visualization').textContent=e.status===404?'No spatial output yet.':e.message;if(e.status!==404)notice(e.message,true);}}}
|
||||
async function selectJob(id){const token=++state.selection;state.job=state.jobs.find(x=>x.id===id)||null;document.querySelectorAll('[data-job]').forEach(button=>button.setAttribute('aria-current',button.dataset.job===id));state.resultLoaded='';clearVisualization();state.logOffset=0;$('#job-log').textContent='';await loadLog(true,token);if(token!==state.selection)return;await loadResult(token);actions();}
|
||||
async function save(){if(state.saving||state.deleting)return false;const invalid=$('.parameter-pane input[type="number"]:invalid');if(invalid){invalid.reportValidity();invalid.focus();notice('Correct the invalid number before saving.',true);return false;}if(state.invalidFields.size){notice('Correct the invalid parameter values before saving.',true);return false;}const name=$('#experiment-name').value.trim();if(!name){notice('Enter an experiment name.',true);return false;}state.draftName=name;state.spec.model.resolution=$('#model-resolution').value;for(const k of ['layers','ncpus'])state.spec.model[k]=Number($(`#model-${k}`).value);state.spec.run.cycles=Number($('#run-cycles').value);state.spec.run.steps=$('#run-steps').value===''?null:Number($('#run-steps').value);const v=++state.saveVersion,revision=state.editVersion,editor=state.spec;state.saving=true;actions();$('#editor-errors').textContent='';try{const d=await request('experiments',{method:'POST',body:{...(state.experiment?.id?{id:state.experiment.id}:{}),name,spec:structuredClone(state.spec)}}),e=d.experiment||d;state.experiments=[e,...state.experiments.filter(item=>item.id!==e.id)];renderExperiments();if(v!==state.saveVersion||editor!==state.spec)return false;state.experiment=e;if(revision!==state.editVersion){notice('Earlier values saved. Current edits are not saved.');return false;}state.draftName=e.name;state.spec=structuredClone(e.spec);state.spec.parameters={...state.spec.parameters};setDirty(false);renderAll();notice('Experiment saved.');return true;}catch(e){showErrors(e);notice(e.message,true);return false;}finally{state.saving=false;actions();}}
|
||||
async function deleteExperiment(){
|
||||
if(state.deleting||!state.experiment)return;
|
||||
const selected=state.experiment,revision=state.editVersion;
|
||||
if(!confirm(`Delete ${selected.name}?`))return;
|
||||
state.deleting=true;actions();
|
||||
try{
|
||||
await request('experiment_delete',{method:'POST',body:{id:selected.id}});
|
||||
state.experiments=state.experiments.filter(item=>item.id!==selected.id);
|
||||
if(state.experiment?.id===selected.id){
|
||||
state.experiment=null;
|
||||
if(revision===state.editVersion){state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();}
|
||||
setDirty();renderAll();
|
||||
}else renderExperiments();
|
||||
notice('Experiment deleted.');
|
||||
}catch(error){notice(error.message,true);}finally{state.deleting=false;actions();}
|
||||
}
|
||||
async function enqueue(){if(state.dirty&&!await save())return;if(!state.experiment)return;try{const j=await request('jobs',{method:'POST',body:{experiment_id:state.experiment.id}});receivedJob(j);if(j.id)await selectJob(j.id);notice('Run added to queue.');}catch(e){notice(e.message,true);}}
|
||||
async function act(n,b){try{const d=await request(n,{method:'POST',body:b});receivedJob(d);if(n==='retry'&&d.id)await selectJob(d.id);else if(state.job)await selectJob(state.job.id);notice('Run updated.');}catch(e){notice(e.message,true);}}
|
||||
function exportExperiment(){if(!state.experiment)return;const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify({name:state.experiment.name,spec:state.experiment.spec},null,2)],{type:'application/json'}));a.download=`${state.experiment.name.replace(/[^a-z0-9_-]+/gi,'-')}.json`;a.click();URL.revokeObjectURL(a.href);}
|
||||
async function importExperiment(file){try{const p=JSON.parse(await file.text());if(!p?.spec||typeof p.spec!=='object'||Array.isArray(p.spec)||['model','parameters','run','surface'].some(k=>p.spec[k]!==undefined&&(!p.spec[k]||typeof p.spec[k]!=='object'||Array.isArray(p.spec[k]))))throw Error('This file does not contain a valid experiment.');state.invalidFields.clear();state.experiment=null;state.draftName=String(p.name||'Imported experiment');const defaults=freshSpec();state.spec={...structuredClone(p.spec),model:{...defaults.model,...p.spec.model},parameters:{...p.spec.parameters},run:{...defaults.run,...p.spec.run}};setDirty();renderAll();notice('Imported parameters. Save the experiment to keep it.');}catch(e){notice(e.message,true);}}
|
||||
async function poll(){if(state.pollBusy||document.hidden)return;state.pollBusy=true;try{await loadJobs();if(state.job){const old=state.job.status;state.job=state.jobs.find(x=>x.id===state.job.id)||state.job;if(old!==state.job.status){state.resultLoaded='';clearVisualization();if(state.job.kind==='planet'&&state.job.status==='completed')await loadExperiments();}else if(state.job.status==='running'&&state.resultLoaded!==`${state.job.id}:${state.job.completed_cycles||0}`){state.resultLoaded='';}if(['queued','running'].includes(state.job.status))await loadLog();if(state.resultLoaded!==state.job.id)await loadResult();actions();}}catch(e){notice(e.message,true);}finally{state.pollBusy=false;}}
|
||||
function randomSeed(){return crypto.getRandomValues(new Uint32Array(1))[0];}
|
||||
document.addEventListener('click',async e=>{const x=e.target.closest('[data-experiment]');if(x){if(state.dirty&&!confirm('Discard unsaved changes?'))return;setExperiment(state.experiments.find(v=>v.id===x.dataset.experiment));return;}const j=e.target.closest('[data-job]');if(j){await selectJob(j.dataset.job);return;}const b=e.target.closest('[data-action]');if(!b)return;if(!state.schema&&!['refresh','cancel','retry'].includes(b.dataset.action)){notice('Model parameters are not loaded. Select Refresh to try again.',true);return;}switch(b.dataset.action){case'edit-maps':await openMaps();break;case'close-maps':closeMaps();break;case'clear-maps':if(confirm('Use the original surface maps?')){delete state.spec.maps;setDirty();renderMaps();}break;case'new':if(state.dirty&&!confirm('Discard unsaved changes?'))return;state.experiment=null;state.draftName='New experiment';state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();break;case'save':await save();break;case'save-run':if(await save())await enqueue();break;case'clone':if(state.experiment){state.draftName=`${state.experiment.name} copy`;state.experiment=null;setDirty();renderAll();}break;case'delete':await deleteExperiment();break;case'export':exportExperiment();break;case'import':$('#import-file').click();break;case'refresh':if(!state.schema)await init();else await Promise.all([loadExperiments(),loadJobs()]);break;case'cancel':if(state.job)await act('cancel',{id:state.job.id});break;case'retry':if(state.job)await act('retry',{id:state.job.id});break;case'reset':if(confirm('Reset parameters to model defaults?')){state.spec=freshSpec();state.invalidFields.clear();setDirty();renderAll();}break;case'generate':{const d=$('#generator-dialog'),f=$('#generator-form'),rules=state.schema.surface;for(const[k,rule]of Object.entries(rules)){const input=f.elements.namedItem(k);input.value=k==='seed'?randomSeed():rule.default;input.min=rule.min;input.max=rule.max;input.step=rule.type==='integer'?'1':'any';}d.showModal();break;}case'regenerate':$('#generator-form').seed.value=randomSeed();break;}});
|
||||
document.addEventListener('input',e=>{if(e.target.id==='experiment-name'){state.draftName=e.target.value;$('#active-experiment-title').textContent=state.draftName||'New experiment';setDirty();}if(e.target.id==='experiment-search')renderExperiments();if(e.target.id==='parameter-search')renderParameters();if(e.target.matches('[data-param]')){const f=(state.schema.fields||[]).find(x=>x.name===e.target.dataset.param),v=e.target.value;if(f?.type==='object'||f?.type==='array'){try{state.spec.parameters[f.name]=v.trim()===''?null:JSON.parse(v);state.invalidFields.delete(f.name);$('#editor-errors').textContent='';setDirty();}catch{state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter valid JSON.`;}}else if((f?.type==='integer'||f?.type==='number')&&e.target.validity.badInput){state.invalidFields.set(f.name,v);setDirty();$('#editor-errors').textContent=`${f.name}: Enter a valid number.`;}else{const value=(f?.type==='integer'||f?.type==='number')?(v===''?null:Number(v)):f?.type==='boolean'?v==='true':v;if(f?.type==='integer'&&value!==null&&!Number.isInteger(value)){state.invalidFields.set(f.name,v);$('#editor-errors').textContent=`${f.name}: Enter a whole number.`;}else{state.invalidFields.delete(f.name);state.spec.parameters[f.name]=value;}setDirty();}}if(e.target.matches('[data-param]')){const key=e.target.dataset.param,toggle=e.target.closest('.parameter-row').querySelector('[data-null]');if(toggle)toggle.checked=!state.invalidFields.has(key)&&state.spec.parameters[key]===null;if(!state.invalidFields.size)$('#editor-errors').textContent='';}if(e.target.closest('.constructor-grid'))setDirty();});
|
||||
document.addEventListener('change',e=>{if(e.target.id==='import-file'&&e.target.files[0])importExperiment(e.target.files[0]);if(e.target.matches('[data-null]')){const k=e.target.dataset.null,control=e.target.closest('.parameter-row').querySelector('[data-param]');if(e.target.checked){state.spec.parameters[k]=null;state.invalidFields.delete(k);if(!state.invalidFields.size)$('#editor-errors').textContent='';control.value='';}else if(control.value!==''){control.dispatchEvent(new Event('input',{bubbles:true}));}else{state.invalidFields.set(k,'');$('#editor-errors').textContent=`${k}: Enter a value or select Default.`;control.focus();}setDirty();}if(e.target.id.startsWith('model-'))state.spec.model[e.target.id.slice(6)]=e.target.id==='model-resolution'?e.target.value:Number(e.target.value);if(e.target.id.startsWith('run-'))state.spec.run[e.target.id.slice(4)]=e.target.id==='run-steps'?(e.target.value===''?null:Number(e.target.value)):Number(e.target.value);});
|
||||
$('#map-editor-dialog').addEventListener('cancel',e=>{e.preventDefault();closeMaps();});
|
||||
$('#generator-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,status=$('#generator-status');const form=new FormData(f),surface={seed:Number(form.get('seed')),land_fraction:Number(form.get('land_fraction')),relief:Number(form.get('relief')),scale:Number(form.get('scale'))},rules=state.schema.surface;for(const[k,v]of Object.entries(surface)){const rule=rules[k]||{};if((typeof rule.min==='number'&&v<rule.min)||(typeof rule.max==='number'&&v>rule.max)){status.textContent=`${k} must be between ${rule.min} and ${rule.max}.`;return;}}status.textContent='';try{const data=await request('generate',{method:'POST',body:{name:state.draftName&&state.draftName!=='New experiment'?state.draftName:`Planet ${surface.seed}`,spec:{...structuredClone(state.spec),surface},surface}});$('#generator-dialog').close();receivedJob(data.job);notice('Planet generation added to queue.');if(data.job?.id)await selectJob(data.job.id);}catch(x){status.textContent=x.message;showErrors(x);}});
|
||||
window.addEventListener('beforeunload',e=>{if(state.dirty||state.mapDirty){e.preventDefault();e.returnValue='';}});
|
||||
async function init(){actions();try{state.schema=await request('schema');state.spec=freshSpec();state.draftName='New experiment';await Promise.all([loadExperiments(),loadJobs()]);renderAll();$('#workbench-notice').classList.remove('visible');if(state.jobs.length)await selectJob(state.jobs[0].id);}catch(e){notice(e.message,true);}if(!state.pollTimer)state.pollTimer=setInterval(poll,3500);if(!state.logTimer)state.logTimer=setInterval(()=>{if(state.job&&['queued','running'].includes(state.job.status)&&!document.hidden)loadLog();},1800);}init();
|
||||
})();
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
// Example component definition file used by the components system:
|
||||
/*<?php return [
|
||||
|
||||
'begin' => function($prop, &$context) {
|
||||
$context['current_component_id'] = $prop['id'];
|
||||
return('<div class="my-component">');
|
||||
},
|
||||
|
||||
'end' => function($prop) {
|
||||
?><script>
|
||||
console.log('Component <?= safe($prop['id']) ?> finalized');
|
||||
</script><?php
|
||||
return('</div>');
|
||||
},
|
||||
|
||||
'render' => function($prop, &$context) {
|
||||
return($context['begin']($prop, $context) . $context['end']($prop));
|
||||
},
|
||||
|
||||
'about' => 'This is an example component that wraps content in a div and logs when it is finalized.',
|
||||
|
||||
]; */
|
||||
|
||||
$GLOBALS['render_funcs'] = array();
|
||||
$GLOBALS['id_counter'] = $GLOBALS['id_counter'] ?? 1;
|
||||
|
||||
function component_error_banner($s)
|
||||
{
|
||||
return '<div class="banner">'.safe($s).'</div>';
|
||||
}
|
||||
|
||||
function component_expand_search_paths($search_path = false)
|
||||
{
|
||||
$paths = array();
|
||||
if(is_array($search_path))
|
||||
$paths = $search_path;
|
||||
else if(is_string($search_path) && trim($search_path) !== '')
|
||||
$paths = array($search_path);
|
||||
|
||||
$normalized = array();
|
||||
foreach($paths as $path)
|
||||
{
|
||||
$base = rtrim((string)$path, '/');
|
||||
if($base === '')
|
||||
continue;
|
||||
$normalized[] = $base;
|
||||
if(substr($base, -11) !== '/components')
|
||||
$normalized[] = $base.'/components';
|
||||
}
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
function component_caller_dir()
|
||||
{
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
|
||||
foreach($trace as $frame)
|
||||
{
|
||||
if(!isset($frame['file']) || !is_string($frame['file']))
|
||||
continue;
|
||||
if(realpath($frame['file']) === realpath(__FILE__))
|
||||
continue;
|
||||
return dirname($frame['file']);
|
||||
}
|
||||
return getcwd();
|
||||
}
|
||||
|
||||
function component_normalize_name($file_name)
|
||||
{
|
||||
return preg_replace('/\.php$/', '', trim((string)$file_name));
|
||||
}
|
||||
|
||||
function component_resolve_file($file_name, $search_path = false)
|
||||
{
|
||||
$component_name = component_normalize_name($file_name);
|
||||
$candidates = array($component_name.'.php');
|
||||
if(!str_starts_with($component_name, 'components/'))
|
||||
$candidates[] = 'components/'.$component_name.'.php';
|
||||
if(!str_starts_with($component_name, 'components/'))
|
||||
{
|
||||
foreach(component_expand_search_paths($search_path) as $path)
|
||||
$candidates[] = $path.'/'.$component_name.'.php';
|
||||
}
|
||||
if(!str_starts_with($component_name, 'interface/'))
|
||||
$candidates[] = 'interface/'.$component_name.'.php';
|
||||
if(!str_starts_with($component_name, 'packages/'))
|
||||
$candidates[] = 'packages/'.$component_name.'.php';
|
||||
foreach(array_unique($candidates) as $candidate)
|
||||
{
|
||||
if(file_exists($candidate))
|
||||
return $candidate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a component from file system and registers it in the global registry
|
||||
*
|
||||
* Component files are searched in this order:
|
||||
* 1. exact file path passed to the loader
|
||||
* 2. components/{component_name}.php
|
||||
* 3. caller-relative path and caller/components/{component_name}.php
|
||||
* 4. interface/{component_name}.php and packages/{component_name}.php
|
||||
*
|
||||
* Component files should return an array with render functions like:
|
||||
* return ['render' => function($prop) { ... }];
|
||||
* ERP-style files that return a single callable are treated as the default render function.
|
||||
*/
|
||||
function component_get_func($file_name, $return_false_if_not_found = false, $search_path = false)
|
||||
{
|
||||
$component_name = component_normalize_name($file_name);
|
||||
$result = [];
|
||||
// Check if component is already loaded in global registry
|
||||
if(!isset($GLOBALS['render_funcs'][$component_name]))
|
||||
{
|
||||
$component_file = component_resolve_file($component_name, $search_path);
|
||||
if($component_file)
|
||||
{
|
||||
$result['component_file'] = $component_file;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Component file not found - create error component
|
||||
if($return_false_if_not_found) return(false);
|
||||
$result['component_file'] = false;
|
||||
$result['error'] = 'Component not found: '.$component_name;
|
||||
$result['render'] = function() use($component_name) {
|
||||
return component_error_banner('component not found: '.$component_name);
|
||||
};
|
||||
}
|
||||
if($result['component_file'])
|
||||
{
|
||||
$component_definition = require($result['component_file']);
|
||||
if(is_callable($component_definition))
|
||||
$result['render'] = $component_definition;
|
||||
else if(is_array($component_definition))
|
||||
{
|
||||
foreach($component_definition as $k => $v)
|
||||
$result[$k] = $v;
|
||||
}
|
||||
else
|
||||
$result['render'] = '';
|
||||
}
|
||||
// Register component in global registry for future use
|
||||
$GLOBALS['render_funcs'][$component_name] = $result;
|
||||
}
|
||||
return($GLOBALS['render_funcs'][$component_name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a component exists without loading it
|
||||
* Returns true if component file can be found, false otherwise
|
||||
*/
|
||||
function component_exists($file_name)
|
||||
{
|
||||
$caller_dir = component_caller_dir();
|
||||
return(component_get_func($file_name, true, $caller_dir) !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a component and returns its definition
|
||||
* Same as component_get_func but with caller directory detection
|
||||
*/
|
||||
function component_load($file_name)
|
||||
{
|
||||
$caller_dir = component_caller_dir();
|
||||
return(component_get_func($file_name, true, $caller_dir));
|
||||
}
|
||||
|
||||
function component_call($file_name, $render_call, $prop = array())
|
||||
{
|
||||
$prop['render_call'] = $render_call;
|
||||
return component($file_name, $prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares an inline component directly in code (not from file)
|
||||
*
|
||||
* @param string $file_name - Component name for registry
|
||||
* @param array $render_prop - Component definition with render functions
|
||||
*
|
||||
* Example:
|
||||
* component_declare('my-button', [
|
||||
* 'render' => function($prop) { return "<button>{$prop['text']}</button>"; }
|
||||
* ]);
|
||||
*/
|
||||
function component_declare($file_name, $render_prop)
|
||||
{
|
||||
// Register the inline component directly in global registry
|
||||
$GLOBALS['render_funcs'][$file_name] = $render_prop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main component rendering function - the heart of the component system
|
||||
*
|
||||
* @param string $file_name - Component name or path
|
||||
* @param array $prop - Properties/data to pass to component
|
||||
* @return mixed - Component output from the selected render method
|
||||
*
|
||||
* Component calling examples:
|
||||
* component('components/example/theme-switcher')
|
||||
* component_call('components/workspace/panel', 'render', ['title' => 'Status'])
|
||||
* component('workspace/panel', ['render_call' => 'render'])
|
||||
*/
|
||||
function component($file_name, $prop = array())
|
||||
{
|
||||
Profiler::log('component '.$file_name, 1);
|
||||
$caller_dir = component_caller_dir();
|
||||
if($file_name == '')
|
||||
return(component_error_banner('[component name empty]'));
|
||||
$file_name = component_normalize_name($file_name);
|
||||
|
||||
// Default render method is 'render', but can be overridden
|
||||
$prop['render_call'] = first($prop['render_call'] ?? false, 'render');
|
||||
|
||||
// Support for calling specific render methods: 'component:method'
|
||||
if(stristr($file_name, ':')) // you can specify which render function to call
|
||||
{
|
||||
$prop['render_call'] = $file_name;
|
||||
$file_name = nibble(':', $prop['render_call']);
|
||||
}
|
||||
|
||||
// Get component definition from registry (load if not already loaded)
|
||||
$renderer = $GLOBALS['render_funcs'][$file_name] ?? false;
|
||||
if(!$renderer)
|
||||
{
|
||||
// Component not in registry - try to load from file
|
||||
component_get_func($file_name, false, $caller_dir);
|
||||
$renderer = $GLOBALS['render_funcs'][$file_name] ?? false;
|
||||
}
|
||||
|
||||
// Generate unique ID for component instance if not provided
|
||||
$prop['id'] = !empty($prop['id']) ? $prop['id'] : 'c'.($GLOBALS['id_counter']++);
|
||||
$prop['filename'] = $file_name;
|
||||
|
||||
if(isset($prop[':before']) && $prop[':before'] !== '')
|
||||
echo $prop[':before'];
|
||||
|
||||
ob_start();
|
||||
if(isset($renderer[$prop['render_call']]) && is_callable($renderer[$prop['render_call']]))
|
||||
$result = ($renderer[$prop['render_call']]($prop, $renderer));
|
||||
else if(isset($renderer[$prop['render_call']]))
|
||||
$result = ($renderer[$prop['render_call']]);
|
||||
else
|
||||
$result = component_error_banner('component render method not found: '.$file_name.':'.$prop['render_call']);
|
||||
$printed_result = ob_get_clean();
|
||||
|
||||
if($result === null)
|
||||
$result = $printed_result;
|
||||
else if(is_string($result))
|
||||
$result .= $printed_result;
|
||||
else
|
||||
echo $printed_result;
|
||||
|
||||
if(isset($prop[':after']) && $prop[':after'] !== '')
|
||||
echo $prop[':after'];
|
||||
|
||||
Profiler::log(false, -1);
|
||||
return($result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/labdb.class.php';
|
||||
require_once __DIR__ . '/parametercatalog.class.php';
|
||||
class Experiment {
|
||||
private static function result(bool $ok, $data = null, array $errors = []): array { return $ok ? ['ok'=>true,'data'=>$data] : ['ok'=>false,'errors'=>$errors]; }
|
||||
private static function row(array $r): array { $r['spec'] = json_decode($r['spec'], true); if (isset($r['spec']['parameters']) && $r['spec']['parameters']===[]) $r['spec']['parameters']=(object)[]; return $r; }
|
||||
private static function specJson(array $spec): string { if (isset($spec['parameters']) && $spec['parameters']===[]) $spec['parameters']=(object)[]; return json_encode($spec,JSON_THROW_ON_ERROR); }
|
||||
public static function all($owner): array {
|
||||
$s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE owner=? ORDER BY updated_at DESC LIMIT 200'); $s->execute([$owner]); return array_map([self::class,'row'],$s->fetchAll());
|
||||
}
|
||||
public static function get($id,$owner): ?array {
|
||||
$s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE id=? AND owner=?'); $s->execute([$id,$owner]); $r=$s->fetch(); return $r ? self::row($r) : null;
|
||||
}
|
||||
public static function save($owner,$data): array {
|
||||
if (!is_array($data) || !is_string($data['name']??null) || trim($data['name'])==='' || !is_array($data['spec']??null)) return self::result(false,null,['experiment'=>'Name and spec are required.']);
|
||||
if (!class_exists('ParameterCatalog') || !method_exists('ParameterCatalog','validate')) return self::result(false,null,['spec'=>'Parameter validation is unavailable.']);
|
||||
$v=ParameterCatalog::validate($data['spec']); if (empty($v['ok'])) return self::result(false,null,$v['errors']??['spec'=>'Invalid spec.']); $data['spec']=$v['spec'];
|
||||
$id=$data['id']??bin2hex(random_bytes(16)); if (!preg_match('/^[a-f0-9]{32}$/',$id)) return self::result(false,null,['id'=>'Invalid experiment ID.']);
|
||||
$db=LabDb::connection(); $old=self::get($id,$owner); $now=gmdate('c');
|
||||
if ($old) { $s=$db->prepare('UPDATE experiments SET name=?,spec=?,updated_at=? WHERE id=? AND owner=?'); $s->execute([trim($data['name']),self::specJson($data['spec']),$now,$id,$owner]); }
|
||||
elseif ($data['id']??false) return self::result(false,null,['id'=>'Experiment not found.']);
|
||||
else { $s=$db->prepare('INSERT INTO experiments VALUES(?,?,?,?,?,?)'); $s->execute([$id,$owner,trim($data['name']),self::specJson($data['spec']),$now,$now]); }
|
||||
return self::result(true,self::get($id,$owner));
|
||||
}
|
||||
public static function delete($id,$owner): array { $s=LabDb::connection()->prepare('DELETE FROM experiments WHERE id=? AND owner=?'); $s->execute([$id,$owner]); return $s->rowCount() ? self::result(true,true) : self::result(false,null,['id'=>'Experiment not found.']); }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
class Filebase
|
||||
{
|
||||
|
||||
static function hash($s = false)
|
||||
{
|
||||
if($s === false) $s = time().random_int(0, 2147483647);
|
||||
return hash('sha256', strtolower(trim((string)$s)));
|
||||
}
|
||||
|
||||
static function validate_path_part($value, $label, $allow_slash = false)
|
||||
{
|
||||
$value = (string)$value;
|
||||
if($value === '')
|
||||
throw new InvalidArgumentException($label.' must not be empty');
|
||||
if(strpos($value, "\0") !== false || strpos($value, "\\") !== false)
|
||||
throw new InvalidArgumentException($label.' contains an unsafe path character');
|
||||
|
||||
$segments = $allow_slash ? explode('/', $value) : array($value);
|
||||
foreach($segments as $segment)
|
||||
{
|
||||
if($segment === '' || $segment === '.' || $segment === '..')
|
||||
throw new InvalidArgumentException($label.' contains an unsafe path segment');
|
||||
if(!$allow_slash && strpos($segment, '/') !== false)
|
||||
throw new InvalidArgumentException($label.' must be a single path segment');
|
||||
}
|
||||
return($value);
|
||||
}
|
||||
|
||||
static function make_bucket_path($p)
|
||||
{
|
||||
$p = self::validate_path_part($p, 'bucket', true);
|
||||
if(stristr($p, '/') !== false)
|
||||
{
|
||||
$seg = explode('/', $p);
|
||||
$p = array_shift($seg);
|
||||
return(substr($p, -2).'/'.$p.'/'.implode('/', $seg));
|
||||
}
|
||||
return(substr($p, -2).'/'.$p);
|
||||
}
|
||||
|
||||
static function storage_path($class, $bucket)
|
||||
{
|
||||
$class = self::validate_path_part($class, 'class');
|
||||
return(first($GLOBALS['config']['filebase']['path'], 'data/').$class.'/'.self::make_bucket_path($bucket));
|
||||
}
|
||||
|
||||
static function data_filename($class, $bucket, $type)
|
||||
{
|
||||
$type = self::validate_path_part($type, 'type');
|
||||
return(self::storage_path($class, $bucket).'/'.$type.'.json');
|
||||
}
|
||||
|
||||
static function write_file($filename, $data)
|
||||
{
|
||||
$directory = dirname($filename);
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0700, true)) return false;
|
||||
$fp = @fopen($filename, "c+");
|
||||
|
||||
if(!$fp)
|
||||
{
|
||||
Log::text('<ERR>', 'file_put_contents_ex('.$filename.') could not write file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (flock($fp, LOCK_EX))
|
||||
{
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
$written = fwrite($fp, $data);
|
||||
fflush($fp);
|
||||
@chmod($filename, 0600);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return $written === strlen($data);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log::text('<ERR>', 'file_put_contents_ex('.$filename.') could not acquire lock');
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
static function delete_file($file_name)
|
||||
{
|
||||
unlink($file_name);
|
||||
}
|
||||
|
||||
static function read_file($file_name)
|
||||
{
|
||||
if(!file_exists($file_name))
|
||||
return('');
|
||||
$fsz = filesize($file_name);
|
||||
if($fsz == 0)
|
||||
return('');
|
||||
|
||||
$fp = fopen($file_name, "rb+");
|
||||
|
||||
if(!$fp)
|
||||
return('');
|
||||
|
||||
if (flock($fp, LOCK_SH)) {
|
||||
$content = fread($fp, $fsz);
|
||||
flock($fp, LOCK_UN);
|
||||
} else {
|
||||
Log::text('<ERR>', 'read_file('.$file_name.') could not acquire lock');
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
return($content);
|
||||
}
|
||||
|
||||
static function write_data($class, $bucket, $type, $data)
|
||||
{
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
if(!file_exists($storage_path)) @mkdir($storage_path, 0774, true);
|
||||
if(!file_exists($storage_path)) @mkdir($storage_path, 0700, true);
|
||||
$json = json_encode($data);
|
||||
if ($json === false) return false;
|
||||
return self::write_file(self::data_filename($class, $bucket, $type), $json);
|
||||
}
|
||||
|
||||
static function read_data($class, $bucket, $type)
|
||||
{
|
||||
return(json_decode(self::read_file(self::data_filename($class, $bucket, $type)), true));
|
||||
}
|
||||
|
||||
static function delete_data($class, $bucket, $type)
|
||||
{
|
||||
return(self::delete_file(self::data_filename($class, $bucket, $type)));
|
||||
}
|
||||
|
||||
static function get_data_filename($class, $bucket, $type)
|
||||
{
|
||||
return(self::data_filename($class, $bucket, $type));
|
||||
}
|
||||
|
||||
static function list_bucket($class, $bucket)
|
||||
{
|
||||
$items = array();
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
if(!is_dir($storage_path))
|
||||
return($items);
|
||||
foreach(scandir($storage_path) as $name)
|
||||
{
|
||||
if(substr($name, 0, 1) != '_' && trim($name) != '' && $name !== '.' && $name !== '..')
|
||||
$items[] = $name;
|
||||
}
|
||||
return($items);
|
||||
}
|
||||
|
||||
static function search_bucket($class, $bucket, $q)
|
||||
{
|
||||
$items = array();
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
if(!is_dir($storage_path))
|
||||
return($items);
|
||||
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($storage_path, FilesystemIterator::SKIP_DOTS)) as $file)
|
||||
{
|
||||
if(!$file->isFile())
|
||||
continue;
|
||||
if(substr($file->getFilename(), -5) !== '.json')
|
||||
continue;
|
||||
$content = @file_get_contents($file->getPathname());
|
||||
if($content === false || strpos($content, $q) === false)
|
||||
continue;
|
||||
$name = substr($file->getPathname(), strlen($storage_path)+1, -5);
|
||||
if(substr($name, 0, 1) != '_' && trim($name) != '')
|
||||
$items[] = $name;
|
||||
}
|
||||
return($items);
|
||||
}
|
||||
|
||||
static function delete_directory($dir)
|
||||
{
|
||||
if(!is_dir($dir))
|
||||
return('');
|
||||
foreach(scandir($dir) as $entry)
|
||||
{
|
||||
if($entry === '.' || $entry === '..')
|
||||
continue;
|
||||
$path = $dir.'/'.$entry;
|
||||
if(is_dir($path) && !is_link($path))
|
||||
{
|
||||
$error = self::delete_directory($path);
|
||||
if($error !== '') return($error);
|
||||
}
|
||||
else if(!@unlink($path))
|
||||
{
|
||||
return('could not delete '.$path);
|
||||
}
|
||||
}
|
||||
if(!@rmdir($dir))
|
||||
return('could not delete '.$dir);
|
||||
return('');
|
||||
}
|
||||
|
||||
static function delete_bucket($class, $bucket)
|
||||
{
|
||||
return(self::delete_directory(self::storage_path($class, $bucket)));
|
||||
}
|
||||
|
||||
static function write_log($class, $bucket, $type, $data)
|
||||
{
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
if(!file_exists($storage_path)) @mkdir($storage_path, 0774, true);
|
||||
WriteToFile($storage_path.'/'.self::validate_path_part($type, 'type').'.log', json_encode($data).chr(10));
|
||||
}
|
||||
|
||||
static function read_log($class, $bucket, $type, $line_count = 8, $offset = false)
|
||||
{
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
return(get_json_tail($storage_path.'/'.self::validate_path_part($type, 'type').'.log', $line_count, $offset));
|
||||
}
|
||||
|
||||
static function line_count($class, $bucket, $type)
|
||||
{
|
||||
$storage_path = self::storage_path($class, $bucket);
|
||||
return(intval(trim(shell_exec('wc -l '.escapeshellarg($storage_path.'/'.self::validate_path_part($type, 'type').'.log')))));
|
||||
}
|
||||
|
||||
static function get_json_tail($from_file, $line_count = 8, $offset = false)
|
||||
{
|
||||
if($offset > 0)
|
||||
{
|
||||
$lines = trim(shell_exec(
|
||||
'tail -n '.escapeshellarg($offset+$line_count).' '.escapeshellarg($from_file).' | head -n '.escapeshellarg($line_count)));
|
||||
}
|
||||
else
|
||||
{
|
||||
$lines = trim(shell_exec(
|
||||
'tail -n '.escapeshellarg($line_count).' '.escapeshellarg($from_file)));
|
||||
}
|
||||
return(json_lines($lines));
|
||||
}
|
||||
|
||||
static function get_tail($from_file, $line_count = 8, $offset = false)
|
||||
{
|
||||
$line_count = intval($line_count);
|
||||
if($offset > 0)
|
||||
{
|
||||
$lines = trim(shell_exec(
|
||||
'tail -n '.escapeshellarg($offset+$line_count).' '.escapeshellarg($from_file).' | head -n '.escapeshellarg($line_count)));
|
||||
}
|
||||
else
|
||||
{
|
||||
$lines = trim(shell_exec(
|
||||
'tail -n '.escapeshellarg($line_count).' '.escapeshellarg($from_file)));
|
||||
}
|
||||
return(explode(chr(10), $lines));
|
||||
}
|
||||
|
||||
static function get_all_lines($from_file)
|
||||
{
|
||||
return(explode(chr(10), trim(file_get_contents($from_file))));
|
||||
}
|
||||
|
||||
static function truncate_log($from_file, $lines = 128)
|
||||
{
|
||||
$lines = intval($lines);
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'trunc_');
|
||||
shell_exec('tail -n '.$lines.' '.escapeshellarg($from_file).' > '.escapeshellarg($tmp).' && cp '.escapeshellarg($tmp).' '.escapeshellarg($from_file));
|
||||
@unlink($tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
static function json_lines($lines)
|
||||
{
|
||||
if($lines == '')
|
||||
{
|
||||
return(array());
|
||||
}
|
||||
else
|
||||
{
|
||||
$result = array();
|
||||
foreach(explode(chr(10), $lines) as $line)
|
||||
$result[] = json_decode($line, true);
|
||||
return($result);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/labdb.class.php';
|
||||
require_once __DIR__ . '/parametercatalog.class.php';
|
||||
require_once __DIR__ . '/maps.class.php';
|
||||
class Job {
|
||||
private static function row(array $r): array { $r['spec']=json_decode($r['spec'],true); if (isset($r['spec']['parameters']) && $r['spec']['parameters']===[]) $r['spec']['parameters']=(object)[]; return $r; }
|
||||
private static function specJson(array $spec): string { if (isset($spec['parameters']) && $spec['parameters']===[]) $spec['parameters']=(object)[]; return json_encode($spec,JSON_THROW_ON_ERROR); }
|
||||
public static function directory($job): string { return rtrim(lab_config()['jobs'],'/') . '/' . $job['id']; }
|
||||
public static function all($owner): array { $s=LabDb::connection()->prepare('SELECT * FROM jobs WHERE owner=? ORDER BY created_at DESC LIMIT 100'); $s->execute([$owner]); return array_map([self::class,'row'],$s->fetchAll()); }
|
||||
public static function get($id,$owner): ?array { $s=LabDb::connection()->prepare('SELECT * FROM jobs WHERE id=? AND owner=?'); $s->execute([$id,$owner]); $r=$s->fetch(); return $r?self::row($r):null; }
|
||||
private static function queue($owner,$name,$experimentId,$spec,$kind='model'): array {
|
||||
$captures=Maps::captures($spec);if(empty($captures['ok']))return ['ok'=>false,'errors'=>$captures['errors']??[]];
|
||||
$id=bin2hex(random_bytes(16));$cycles=$kind==='planet'?1:(int)($spec['run']['cycles']??1);$now=gmdate('c');$db=LabDb::connection();
|
||||
$db->beginTransaction();try{$q=$db->query("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')");if((int)$q->fetchColumn()>=(int)(lab_config()['queue_limit']??100)){$db->rollBack();return ['ok'=>false,'errors'=>['queue'=>'The job queue is full.']];}
|
||||
$s=$db->prepare("INSERT INTO jobs(id,experiment_id,owner,name,spec,kind,status,phase,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,?,'queued','queued',?,?,?)");$s->execute([$id,$experimentId,$owner,$name,self::specJson($spec),$kind,$cycles,$now,$now]);$insert=$db->prepare('INSERT INTO job_maps(job_id,field,version_id,digest,data) VALUES(?,?,?,?,?)');foreach($captures['data'] as $field=>$map){$insert->bindValue(1,$id);$insert->bindValue(2,$field);$insert->bindValue(3,$map['id']);$insert->bindValue(4,$map['digest']);$insert->bindValue(5,$map['data'],PDO::PARAM_LOB);$insert->execute();}$db->commit();
|
||||
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();return ['ok'=>false,'error'=>'Could not queue job.'];}return ['ok'=>true,'data'=>self::get($id,$owner)];
|
||||
}
|
||||
public static function enqueue($owner,$experiment): array {
|
||||
if(!is_array($experiment)||empty($experiment['id']))return ['ok'=>false,'errors'=>['experiment'=>'A saved experiment is required.']];$s=LabDb::connection()->prepare('SELECT * FROM experiments WHERE id=? AND owner=?');$s->execute([$experiment['id'],$owner]);$e=$s->fetch();if(!$e)return ['ok'=>false,'errors'=>['experiment'=>'Experiment not found.']];
|
||||
$v=ParameterCatalog::validate(json_decode($e['spec'],true));if(empty($v['ok']))return ['ok'=>false,'errors'=>$v['errors']??[]];$mapCheck=Maps::validate($v['spec']);if(empty($mapCheck['ok']))return $mapCheck;return self::queue($owner,$e['name'],$e['id'],$v['spec']);
|
||||
}
|
||||
public static function enqueueKind($owner,$kind,$name,$spec): array {
|
||||
if($kind!=='planet')return ['ok'=>false,'errors'=>['kind'=>'Unsupported job kind.']];if(!is_string($name)||trim($name)==='')return ['ok'=>false,'errors'=>['name'=>'A name is required.']];$v=ParameterCatalog::validate($spec);if(empty($v['ok']))return ['ok'=>false,'errors'=>$v['errors']??[]];if(empty($v['spec']['surface']))return ['ok'=>false,'errors'=>['surface'=>'Planet generation parameters are required.']];$mapCheck=Maps::validate($v['spec']);if(empty($mapCheck['ok']))return $mapCheck;return self::queue($owner,trim($name),null,$v['spec'],'planet');
|
||||
}
|
||||
public static function cancel($id,$owner): array { $db=LabDb::connection();$s=$db->prepare("UPDATE jobs SET status=CASE WHEN status='queued' THEN 'cancelled' ELSE status END,cancel_requested=1,phase=CASE WHEN status='queued' THEN 'cancelled' ELSE phase END,finished_at=CASE WHEN status='queued' THEN ? ELSE finished_at END,updated_at=? WHERE id=? AND owner=? AND status IN ('queued','running')");$now=gmdate('c');$s->execute([$now,$now,$id,$owner]);$r=self::get($id,$owner);return $r?['ok'=>true,'data'=>$r]:['ok'=>false,'errors'=>['id'=>'Active job not found.']]; }
|
||||
public static function retry($id,$owner): array {
|
||||
$j=self::get($id,$owner);if(!$j||!in_array($j['status'],['failed','cancelled','interrupted'],true))return ['ok'=>false,'errors'=>['id'=>'Retry requires a stopped job.']];$newId=bin2hex(random_bytes(16));$now=gmdate('c');$db=LabDb::connection();
|
||||
$db->beginTransaction();try{$q=$db->query("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')");if((int)$q->fetchColumn()>=(int)(lab_config()['queue_limit']??100)){$db->rollBack();return ['ok'=>false,'errors'=>['queue'=>'The job queue is full.']];}$cycles=$j['kind']==='planet'?1:(int)($j['spec']['run']['cycles']??1);$s=$db->prepare("INSERT INTO jobs(id,experiment_id,owner,name,spec,kind,status,phase,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,?,'queued','queued',?,?,?)");$s->execute([$newId,$j['experiment_id'],$owner,$j['name'],self::specJson($j['spec']),$j['kind'],$cycles,$now,$now]);$copy=$db->prepare('INSERT INTO job_maps(job_id,field,version_id,digest,data) SELECT ?,field,version_id,digest,data FROM job_maps WHERE job_id=?');$copy->execute([$newId,$j['id']]);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();return ['ok'=>false,'error'=>'Could not queue retry.'];}return ['ok'=>true,'data'=>self::get($newId,$owner)];
|
||||
}
|
||||
public static function mapCaptures(string $id): array { $q=LabDb::connection()->prepare('SELECT field,version_id,digest,data FROM job_maps WHERE job_id=? ORDER BY field');$q->execute([$id]);$maps=[];foreach($q->fetchAll() as $row)$maps[$row['field']]=['id'=>$row['version_id'],'digest'=>$row['digest'],'data'=>$row['data']];return $maps; }
|
||||
public static function validatedMapCaptures(array $job): array {
|
||||
$expected=$job['spec']['maps']??[];$captures=self::mapCaptures($job['id']);
|
||||
$fields=array_keys($expected);sort($fields);
|
||||
if(array_keys($captures)!==$fields)return ['ok'=>false,'error'=>'Stored job map captures do not match the job spec.'];
|
||||
foreach($expected as $field=>$id)if(($captures[$field]['id']??null)!==$id||!hash_equals((string)($captures[$field]['digest']??''),hash('sha256',(string)($captures[$field]['data']??''))))return ['ok'=>false,'error'=>'Stored job map capture failed validation.'];
|
||||
return ['ok'=>true,'data'=>$captures];
|
||||
}
|
||||
public static function log($id,$owner,$offset=0): array { $j=self::get($id,$owner);if(!$j)return ['ok'=>false,'errors'=>['id'=>'Job not found.']];$path=self::directory($j).'/worker.log';$offset=max(0,(int)$offset);if(!is_file($path))return ['ok'=>true,'data'=>['text'=>'','offset'=>0,'next_offset'=>0]];$size=filesize($path);$offset=min($offset,$size);if($offset<$size-65536)$offset=$size-65536;$f=fopen($path,'rb');fseek($f,$offset);$text=fread($f,65536);$next=ftell($f);fclose($f);return ['ok'=>true,'data'=>['text'=>$text,'offset'=>$offset,'next_offset'=>$next]]; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../config/lab.php';
|
||||
class LabDb {
|
||||
private static ?PDO $pdo = null;
|
||||
public static function connection(): PDO {
|
||||
if (self::$pdo) return self::$pdo;
|
||||
$cfg = lab_config();
|
||||
$dir = dirname($cfg['database']);
|
||||
if (!is_dir($dir)) mkdir($dir, 0750, true);
|
||||
$oldUmask = umask(0007);
|
||||
$pdo = new PDO('sqlite:' . $cfg['database'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||
umask($oldUmask);
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
$pdo->exec((string)file_get_contents(dirname(__DIR__) . '/server/schema.sql'));
|
||||
foreach ([$cfg['database'], $cfg['database'].'-wal', $cfg['database'].'-shm'] as $file) if (is_file($file)) @chmod($file, 0660);
|
||||
return self::$pdo = $pdo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
class Log
|
||||
{
|
||||
|
||||
static $app_name = 'webappstarter';
|
||||
|
||||
static function make($module, $text)
|
||||
{
|
||||
if(is_array($text))
|
||||
$text = str_replace(array('"', '\\'), '', json_encode($text));
|
||||
$seg = array();
|
||||
$seg[] = first($_SESSION['username'] ?? false, 'anonymous');
|
||||
$seg[] = first($_SERVER['REMOTE_ADDR'] ?? false, 'cli');
|
||||
$seg[] = round(memory_get_peak_usage()/1024).'kB';
|
||||
$seg[] = round(Profiler::get_time()).'ms';
|
||||
$seg[] = $module;
|
||||
$seg[] = $text;
|
||||
return($seg);
|
||||
}
|
||||
|
||||
static function audit($module, $text = '', $class = 'warning')
|
||||
{
|
||||
$seg = self::make($module, $text);
|
||||
shell_exec('echo '.escapeshellarg(implode('|', $seg)).' | systemd-cat -t '.(self::$app_name).' -p '.escapeshellarg($class));
|
||||
}
|
||||
|
||||
static function debug($module, $text)
|
||||
{
|
||||
write_to_file('log/debug.'.gmdate('Y-m').'.log',
|
||||
implode(chr(9), self::make($module, $text)).chr(10));
|
||||
}
|
||||
|
||||
static function text($module, $text)
|
||||
{
|
||||
write_to_file('log/log.'.gmdate('Y-m').'.log',
|
||||
implode(chr(9), self::make($module, $text)).chr(10));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/labdb.class.php';
|
||||
final class MapCatalog {
|
||||
public static function schema(): ?array {
|
||||
$path=lab_config()['catalog'];
|
||||
if(!is_file($path)||!is_readable($path))return null;
|
||||
$schema=json_decode((string)file_get_contents($path),true);
|
||||
return is_array($schema)&&isset($schema['maps']['fields'],$schema['maps']['grids'])?$schema['maps']:null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/labdb.class.php';
|
||||
require_once __DIR__ . '/mapcatalog.class.php';
|
||||
final class Maps {
|
||||
private static function error(string $field,string $message): array { return ['ok'=>false,'errors'=>[$field=>$message]]; }
|
||||
public static function all(string $resolution): array {
|
||||
$schema=MapCatalog::schema();if(!$schema||!isset($schema['grids'][$resolution]))return self::error('resolution','Unsupported map resolution.');
|
||||
$q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,created_at FROM map_versions WHERE owner=? AND resolution=? ORDER BY created_at DESC LIMIT 200');$q->execute([lab_config()['workspace'],$resolution]);
|
||||
return ['ok'=>true,'data'=>['maps'=>$q->fetchAll(),'fields'=>$schema['fields'],'grid'=>$schema['grids'][$resolution]]];
|
||||
}
|
||||
public static function save(string $field,string $resolution,string $name,string $bytes): array {
|
||||
$schema=MapCatalog::schema();$contract=$schema['fields'][$field]??null;$shape=$schema['grids'][$resolution]??null;
|
||||
if(!$contract||!$shape)return self::error('field','Unsupported map field or resolution.');
|
||||
if(strlen($name)>120||trim($name)==='')return self::error('name','A map name is required (maximum 120 bytes).');
|
||||
$rows=(int)$shape['nlat'];$cols=(int)$shape['nlon'];$count=$rows*$cols;if(strlen($bytes)!==$count*4)return self::error('map','Map byte count does not match its grid.');
|
||||
$values=unpack('g*',$bytes);if(!$values||count($values)!==$count)return self::error('map','Map data is invalid.');
|
||||
foreach($values as $value)if(!is_finite($value)||$value<$contract['min']||$value>$contract['max'])return self::error('map','Map values must be finite and within the catalog range.');
|
||||
if(!empty($contract['categorical']))foreach($values as $value)if($value!==0.0&&$value!==1.0)return self::error('map','Categorical map values must be 0 or 1.');
|
||||
$id=bin2hex(random_bytes(16));$digest=hash('sha256',$bytes);$db=LabDb::connection();$q=$db->prepare('INSERT INTO map_versions(id,owner,field,resolution,name,rows,cols,digest,data,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)');
|
||||
$q->bindValue(1,$id);$q->bindValue(2,lab_config()['workspace']);$q->bindValue(3,$field);$q->bindValue(4,$resolution);$q->bindValue(5,trim($name));$q->bindValue(6,$rows,PDO::PARAM_INT);$q->bindValue(7,$cols,PDO::PARAM_INT);$q->bindValue(8,$digest);$q->bindValue(9,$bytes,PDO::PARAM_LOB);$q->bindValue(10,gmdate('c'));$q->execute();
|
||||
return ['ok'=>true,'data'=>['id'=>$id,'field'=>$field,'resolution'=>$resolution,'name'=>trim($name),'shape'=>[$rows,$cols],'digest'=>$digest]];
|
||||
}
|
||||
public static function get(string $id): ?array { if(!preg_match('/^[a-f0-9]{32}$/',$id))return null;$q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,data,created_at FROM map_versions WHERE id=? AND owner=?');$q->execute([$id,lab_config()['workspace']]);return $q->fetch()?:null; }
|
||||
public static function validate(array $spec): array {
|
||||
$maps=$spec['maps']??[];if(!is_array($maps)||(array_is_list($maps)&&$maps!==[]))return self::error('maps','Expected a map version object.');$schema=MapCatalog::schema();
|
||||
foreach(array_diff(array_keys($maps),array_keys($schema['fields']??[])) as $key)return self::error('maps.'.$key,'Unsupported map field.');$resolution=$spec['model']['resolution']??'';
|
||||
foreach($maps as $field=>$id){if(!is_string($id)||!($map=self::get($id)))return self::error('maps.'.$field,'Map version not found.');if($map['field']!==$field||$map['resolution']!==$resolution)return self::error('maps.'.$field,'Map field or resolution does not match the job.');if(!hash_equals($map['digest'],hash('sha256',$map['data'])))return self::error('maps.'.$field,'Stored map digest does not match.');}
|
||||
return ['ok'=>true,'data'=>true];
|
||||
}
|
||||
public static function captures(array $spec): array {
|
||||
$valid=self::validate($spec);if(empty($valid['ok']))return $valid;$captures=[];
|
||||
foreach($spec['maps']??[] as $field=>$id){$map=self::get($id);$captures[$field]=['id'=>$id,'digest'=>$map['digest'],'data'=>$map['data']];}
|
||||
return ['ok'=>true,'data'=>$captures];
|
||||
}
|
||||
public static function writeSnapshots(array $captures,string $jobDir): array {
|
||||
if(!$captures)return ['ok'=>true,'data'=>[]];$target=$jobDir.'/inputs/maps';if(!is_dir($target)&&!mkdir($target,0750,true)&&!is_dir($target))return self::error('maps','Could not create job map snapshot.');$paths=[];
|
||||
foreach($captures as $field=>$capture){$bytes=$capture['data']??'';if(!is_string($bytes)||!hash_equals((string)($capture['digest']??''),hash('sha256',$bytes)))return self::error('maps.'.$field,'Captured map digest does not match.');$path=$target.'/'.$field.'.f32';if(file_put_contents($path,$bytes,LOCK_EX)!==strlen($bytes))return self::error('maps.'.$field,'Could not write map snapshot.');$paths[$field]=$path;}
|
||||
return ['ok'=>true,'data'=>$paths];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/labdb.class.php';
|
||||
class ParameterCatalog {
|
||||
public static function schema(): ?array {
|
||||
$path=lab_config()['catalog'];
|
||||
if (!is_file($path)||!is_readable($path)) return null;
|
||||
$schema=json_decode((string)file_get_contents($path),true);
|
||||
return is_array($schema)&&isset($schema['fields'],$schema['model'],$schema['run'],$schema['model_defaults'],$schema['run_defaults'])?$schema:null;
|
||||
}
|
||||
private static function value($value,array $rule): array {
|
||||
if($value===null) return !empty($rule['nullable'])?[$value,null]:[$value,'A value is required.'];
|
||||
$types=$rule['types']??[$rule['type']??''];
|
||||
$type=is_bool($value)?'boolean':(is_int($value)?'integer':(is_float($value)?'number':(is_string($value)?'string':(is_array($value)?(array_is_list($value)?'array':'object'):'invalid'))));
|
||||
if($type==='integer'&&in_array('number',$types,true))$type='number';
|
||||
if(!in_array($type,$types,true))return[$value,'Expected '.implode(' or ',$types).'.'];
|
||||
if(is_int($value)||is_float($value)){if(!is_finite((float)$value))return[$value,'Use a finite number.'];if((isset($rule['min'])&&$value<$rule['min'])||(isset($rule['max'])&&$value>$rule['max']))return[$value,'The value is outside the allowed range.'];}
|
||||
if(isset($rule['choices'])&&!in_array($value,$rule['choices'],true))return[$value,'Select an allowed value.'];
|
||||
if(is_string($value)&&(mb_strlen($value)>200||(isset($rule['pattern'])&&!preg_match('~^(?:'.$rule['pattern'].')$~D',$value))))return[$value,'The value has an invalid format.'];
|
||||
if(is_array($value)&&!array_is_list($value)){
|
||||
$properties=$rule['properties']??[];$unknown=array_diff(array_keys($value),array_keys($properties));if($unknown){sort($unknown);return[$value,'Unknown keys: '.implode(', ',$unknown).'.'];}
|
||||
$result=is_array($rule['default']??null)?$rule['default']:[];
|
||||
foreach($value as $key=>$item){if(!isset($properties[$key]))continue;[$normalized,$error]=self::value($item,$properties[$key]);if($error)return[$value,$key.': '.$error];$result[$key]=$normalized;}
|
||||
$missing=array_diff($rule['required']??[],array_keys($result));if($missing){sort($missing);return[$value,'Required keys: '.implode(', ',$missing).'.'];}$value=$result;
|
||||
}
|
||||
return[$value,null];
|
||||
}
|
||||
public static function validate($spec): array {
|
||||
$schema=self::schema();if(!$schema)return['ok'=>false,'spec'=>$spec,'errors'=>['schema'=>'Parameter catalog is unavailable.']];
|
||||
if(!is_array($spec)|| (array_is_list($spec)&&$spec!==[]))return['ok'=>false,'spec'=>$spec,'errors'=>['spec'=>'Expected an object.']];
|
||||
$errors=[];$normalized=[];
|
||||
foreach(array_diff(array_keys($spec),['model','run','parameters','surface','maps']) as $key)$errors[$key]='Unknown spec section.';
|
||||
foreach(['model','run'] as $section){$values=array_key_exists($section,$spec)?$spec[$section]:[];if(!is_array($values)||(array_is_list($values)&&$values!==[])){$errors[$section]='Expected an object.';continue;}
|
||||
$normalized[$section]=$section==='model'?($schema['model_defaults']):$schema['run_defaults'];
|
||||
foreach($values as $key=>$value){if(!isset($schema[$section][$key])){$errors[$section.'.'.$key]='Unknown field.';continue;}[$result,$error]=self::value($value,$schema[$section][$key]);if($error)$errors[$section.'.'.$key]=$error;else $normalized[$section][$key]=$result;}
|
||||
}
|
||||
$parameters=array_key_exists('parameters',$spec)?$spec['parameters']:[];if($parameters===[])$parameters=[];$normalized['parameters']=[];
|
||||
if(!is_array($parameters)||(array_is_list($parameters)&&$parameters!==[]))$errors['parameters']='Expected an object.';
|
||||
else {$fields=[];foreach($schema['fields'] as $field)$fields[$field['name']]=$field;
|
||||
foreach($parameters as $name=>$value){$field=$fields[$name]??null;if(!$field){$errors['parameters.'.$name]='Unknown parameter.';continue;}
|
||||
if(empty($field['editable'])){if($value!==($field['default']??null))$errors['parameters.'.$name]='This input is managed by the application.';continue;}
|
||||
$rule=$field;if($name==='ozone')$rule['types']=['boolean','object'];
|
||||
if($name==='vegetation'&&($value===true||$value===false)){$normalized['parameters'][$name]=$value;continue;}
|
||||
[$result,$error]=self::value($value,$rule);if($error)$errors['parameters.'.$name]=$error;else $normalized['parameters'][$name]=$result;
|
||||
}
|
||||
}
|
||||
if(array_key_exists('maps',$spec)){$maps=$spec['maps'];$rules=$schema['maps']['fields']??[];if(!is_array($maps)||(array_is_list($maps)&&$maps!==[]))$errors['maps']='Expected a map version object.';else{$normalized['maps']=[];foreach($maps as $field=>$id){if(!isset($rules[$field])){$errors['maps.'.$field]='Unsupported map field.';continue;}if(!is_string($id)||!preg_match('/^[a-f0-9]{32}$/',$id))$errors['maps.'.$field]='Expected a saved map version ID.';else $normalized['maps'][$field]=$id;}}}
|
||||
if(array_key_exists('surface',$spec)){$surface=$spec['surface'];$rules=$schema['surface']??[];if(!is_array($surface)||(array_is_list($surface)&&$surface!==[]))$errors['surface']='Expected an object.';else{$normalized['surface']=[];foreach(array_diff(array_keys($surface),array_keys($rules)) as $key)$errors['surface.'.$key]='Unknown field.';foreach($rules as $key=>$rule){$item=array_key_exists($key,$surface)?$surface[$key]:($rule['default']??null);[$result,$error]=self::value($item,$rule);if($error)$errors['surface.'.$key]=$error;else $normalized['surface'][$key]=$result;}}}
|
||||
return['ok'=>!$errors,'spec'=>$normalized,'errors'=>$errors];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
class Profiler
|
||||
{
|
||||
|
||||
static $log = array();
|
||||
static $start = 0;
|
||||
static $last = 0;
|
||||
static $current = 0;
|
||||
static $indent_str = '';
|
||||
static $indent_level = 0;
|
||||
|
||||
# makes a commented profiler entry
|
||||
static function log($text, $indent_delta = 0)
|
||||
{
|
||||
$thistime = microtime(true);
|
||||
$absoluteMS = $thistime - self::$start;
|
||||
self::$indent_level += $indent_delta;
|
||||
if($indent_delta < 0) // if less than zero, update indent before logging
|
||||
self::$indent_str = str_repeat(' ', self::$indent_level * 2);
|
||||
if($text) self::$log[] =
|
||||
number_format($absoluteMS*1000, 3).'ms | '.
|
||||
number_format(1000*($thistime - self::$last), 3).'ms | '.
|
||||
ceil(memory_get_usage()/1024).' kB | '.self::$indent_str.$text;
|
||||
if($indent_delta > 0) // if greater than zero, update indent after logging
|
||||
self::$indent_str = str_repeat(' ', self::$indent_level * 2);
|
||||
self::$last = $thistime;
|
||||
self::$current = $absoluteMS;
|
||||
return($thistime);
|
||||
}
|
||||
|
||||
static function get_time()
|
||||
{
|
||||
return(1000*(microtime(true) - self::$start));
|
||||
}
|
||||
|
||||
static function start()
|
||||
{
|
||||
self::$start = self::$last = microtime(true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
require_once __DIR__.'/job.class.php';
|
||||
class Results {
|
||||
public static function manifest(array $job): ?array {
|
||||
$path=Job::directory($job).'/artifacts/index.json'; if(!is_file($path)) return null;
|
||||
$data=json_decode((string)file_get_contents($path),true); return is_array($data)&&is_array($data['datasets']??null)?$data:null;
|
||||
}
|
||||
public static function dataset(array $job,string $id): ?array {
|
||||
if(!preg_match('/^[a-f0-9]{32}$/',$id)) return null;
|
||||
$manifest=self::manifest($job); if(!$manifest||!in_array($id,array_column($manifest['datasets'],'id'),true)) return null;
|
||||
$path=Job::directory($job).'/artifacts/datasets/'.$id.'/index.json'; if(!is_file($path)) return null;
|
||||
$index=json_decode((string)file_get_contents($path),true); return is_array($index)?$index:null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
spl_autoload_register(function ($class_name) {
|
||||
$classFile = 'lib/'.strtolower($class_name).'.class.php';
|
||||
if(file_exists($classFile))
|
||||
{
|
||||
include($classFile);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
Profiler::start();
|
||||
|
||||
function url_root()
|
||||
{
|
||||
$root = (string)cfg('url/root');
|
||||
if($root !== '' && substr($root, -1) !== '/')
|
||||
$root .= '/';
|
||||
return $root;
|
||||
}
|
||||
|
||||
function get_file_location($file, $error_if_not_found = true)
|
||||
{
|
||||
$file = ltrim((string)$file, '/');
|
||||
if(str_contains($file, '..'))
|
||||
{
|
||||
if($error_if_not_found)
|
||||
die('invalid file path: '.$file);
|
||||
return false;
|
||||
}
|
||||
if(file_exists($file)) return $file;
|
||||
foreach((array)$GLOBALS['config']['site']['include_paths'] as $path)
|
||||
{
|
||||
$path = rtrim((string)$path, '/').'/';
|
||||
if(file_exists($path.$file)) return $path.$file;
|
||||
}
|
||||
if($error_if_not_found)
|
||||
die('file not found: '.$file);
|
||||
return false;
|
||||
}
|
||||
|
||||
function asset_already_included($file_location)
|
||||
{
|
||||
static $included_assets = array();
|
||||
if(isset($included_assets[$file_location]))
|
||||
return true;
|
||||
$included_assets[$file_location] = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function include_js($src_file)
|
||||
{
|
||||
if(!($file_location = get_file_location($src_file))) return;
|
||||
if(asset_already_included($file_location)) return;
|
||||
?><script src="<?= url_root().$file_location ?>?v=<?= filemtime($file_location) ?>"></script><?php
|
||||
}
|
||||
|
||||
function include_css($src_file)
|
||||
{
|
||||
if(!($file_location = get_file_location($src_file))) return;
|
||||
if(asset_already_included($file_location)) return;
|
||||
?><link rel="stylesheet" href="<?= url_root().$file_location ?>?v=<?= filemtime($file_location) ?>" /><?php
|
||||
}
|
||||
|
||||
# **************************** GENERAL UTILITY FUNCTIONS ******************************
|
||||
|
||||
// escapes a string for use in HTML attributes
|
||||
function asafe($s)
|
||||
{
|
||||
if($s === null) return '';
|
||||
return htmlspecialchars(str_replace(array("\r", "\n"), ' ', $s), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// escapes a string for use in HTML text
|
||||
function safe($s)
|
||||
{
|
||||
if($s === null) return '';
|
||||
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function jsafe($s)
|
||||
{
|
||||
return json_encode($s);
|
||||
}
|
||||
|
||||
function short_id($value, $front = 10, $back = 8)
|
||||
{
|
||||
$value = (string)$value;
|
||||
$front = max(0, (int)$front);
|
||||
$back = max(0, (int)$back);
|
||||
if(strlen($value) <= ($front + $back + 1)) return $value;
|
||||
return substr($value, 0, $front).'…'.substr($value, -$back);
|
||||
}
|
||||
|
||||
function id_token($value, $label = '')
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if($value === '') return '';
|
||||
$label = $label !== '' ? (string)$label : short_id($value);
|
||||
return '<span class="id-token" title="'.asafe($value).'" data-copy="'.asafe($value).'"><span class="id-token-text">'.safe($label).'</span><button type="button" class="copy-token" data-copy="'.asafe($value).'" aria-label="Copy '.asafe($value).'">Copy</button></span>';
|
||||
}
|
||||
|
||||
function clamp($v, $min, $max)
|
||||
{
|
||||
if($v < $min) return $min;
|
||||
if($v > $max) return $max;
|
||||
return $v;
|
||||
}
|
||||
|
||||
function pick_entry_from_range($array, $value)
|
||||
{
|
||||
if(!is_array($array)) return [];
|
||||
$result = [];
|
||||
foreach($array as $pv)
|
||||
if($value >= $pv['from'] && $value <= $pv['to']) $result = $pv;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can have any number of arguments. Returns the first of its arguments that is not false, empty string, or null.
|
||||
*/
|
||||
function first()
|
||||
{
|
||||
$args = func_get_args();
|
||||
foreach($args as $v)
|
||||
{
|
||||
if(isset($v) && $v !== false && $v !== '' && $v !== null)
|
||||
return($v);
|
||||
}
|
||||
return('');
|
||||
}
|
||||
|
||||
function alnum($s, $replace_with = '_', $trim = true)
|
||||
{
|
||||
if($trim) $s = trim(strtolower($s));
|
||||
return(preg_replace("/[^[:alnum:][:space:]]/u", $replace_with, $s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a string to the given file.
|
||||
*/
|
||||
function write_to_file($filename, $content)
|
||||
{
|
||||
if (is_array($content)) $content = json_encode($content);
|
||||
$open = fopen($filename, 'a+');
|
||||
fwrite($open, $content);
|
||||
fclose($open);
|
||||
@chmod($filename, 0777);
|
||||
}
|
||||
|
||||
# **************************** ARRAY FUNCTIONS ******************************
|
||||
|
||||
/**
|
||||
* Returns a value from the $GLOBALS['config'] array identified by $key.
|
||||
* Sub-array values can be addressed by using the '/' character as a separator.
|
||||
*/
|
||||
function cfg($key)
|
||||
{
|
||||
$config = $GLOBALS['config'];
|
||||
$seg = explode('/', $key);
|
||||
$lastSeg = array_pop($seg);
|
||||
foreach($seg as $s)
|
||||
{
|
||||
if(is_array($config) && array_key_exists($s, $config) && is_array($config[$s]))
|
||||
$config = $config[$s];
|
||||
else
|
||||
return null;
|
||||
}
|
||||
if(!is_array($config) || !array_key_exists($lastSeg, $config))
|
||||
return null;
|
||||
return($config[$lastSeg]);
|
||||
}
|
||||
|
||||
|
||||
# **************************** STRING/FORMATTING FUNCTIONS ******************************
|
||||
|
||||
/**
|
||||
* Convert any base number into another number of another base system.
|
||||
*/
|
||||
function base_convert_any($numberInput, $fromBaseInput, $toBaseInput)
|
||||
{
|
||||
if ($fromBaseInput==$toBaseInput) return $numberInput;
|
||||
$fromBase = str_split($fromBaseInput,1);
|
||||
$toBase = str_split($toBaseInput,1);
|
||||
$number = str_split($numberInput,1);
|
||||
$fromLen=strlen($fromBaseInput);
|
||||
$toLen=strlen($toBaseInput);
|
||||
$numberLen=strlen($numberInput);
|
||||
$retval='';
|
||||
if ($toBaseInput == '0123456789')
|
||||
{
|
||||
$retval=0;
|
||||
for ($i = 1;$i <= $numberLen; $i++)
|
||||
$retval = bcadd($retval, bcmul(array_search($number[$i-1], $fromBase),bcpow($fromLen,$numberLen-$i)));
|
||||
return $retval;
|
||||
}
|
||||
if ($fromBaseInput != '0123456789')
|
||||
$base10=base_convert_any($numberInput, $fromBaseInput, '0123456789');
|
||||
else
|
||||
$base10 = $numberInput;
|
||||
if ($base10<strlen($toBaseInput))
|
||||
return $toBase[$base10];
|
||||
while($base10 != '0')
|
||||
{
|
||||
$retval = $toBase[bcmod($base10,$toLen)].$retval;
|
||||
$base10 = bcdiv($base10,$toLen,0);
|
||||
}
|
||||
return $retval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Unix timestamp into a human-friendly short form.
|
||||
*/
|
||||
function age_to_string($unixDate, $new = 'just now', $ago = 'ago')
|
||||
{
|
||||
if($unixDate == 0) return('-');
|
||||
$result = '';
|
||||
$oneMinute = 60;
|
||||
$oneHour = $oneMinute*60;
|
||||
$oneDay = $oneHour*24;
|
||||
|
||||
$difference = time() - $unixDate;
|
||||
|
||||
if ($difference < $oneMinute)
|
||||
$result = $new;
|
||||
else if ($difference < $oneHour)
|
||||
$result = round($difference/$oneMinute).' min '.$ago;
|
||||
else if ($difference < $oneDay)
|
||||
$result = floor($difference/$oneHour).' h '.$ago;
|
||||
else if ($difference < $oneDay*5)
|
||||
$result = gmdate('D H:i', $unixDate);
|
||||
else if ($difference < $oneDay*365)
|
||||
$result = gmdate('M dS H:i', $unixDate);
|
||||
else
|
||||
$result = date('d. M Y H:i', $unixDate);
|
||||
return($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the separator string $segdiv, cut a piece of &$cake off that precedes $segdiv,
|
||||
* and return that piece. If there are no instances of $segdiv in &$cake, nibble()
|
||||
* returns the entirety of &$cake and sets &$cake to an empty string.
|
||||
*/
|
||||
function nibble($segdiv, &$cake, &$found = false)
|
||||
{
|
||||
$p = strpos($cake, $segdiv);
|
||||
if ($p === false)
|
||||
{
|
||||
$result = $cake;
|
||||
$cake = '';
|
||||
$found = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
$result = substr($cake, 0, $p);
|
||||
$cake = substr($cake, $p + strlen($segdiv));
|
||||
$found = true;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function make_hash($s = false, $length = 10)
|
||||
{
|
||||
if($s === false) $s = time().random_int(0, 2147483647);
|
||||
$s = strtolower(substr(trim($s), 0, 64));
|
||||
return(substr(base_convert_ex(
|
||||
sha1(sha1('qw0e983124o521öl34u9087'.$s)),
|
||||
'0123456789abcdef',
|
||||
'0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
), -$length));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
class URL
|
||||
{
|
||||
static $route = [];
|
||||
static $fragments = [];
|
||||
static $error = '';
|
||||
|
||||
static function Link($page = '', $params = [])
|
||||
{
|
||||
if ($page !== '') $params = ['page' => $page] + $params;
|
||||
$query = http_build_query($params);
|
||||
return cfg('url/root') . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS experiments (
|
||||
id TEXT PRIMARY KEY CHECK(length(id)=32), owner TEXT NOT NULL, name TEXT NOT NULL,
|
||||
spec TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS experiments_owner_updated ON experiments(owner, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY CHECK(length(id)=32), experiment_id TEXT, owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL, spec TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'model' CHECK(kind IN ('model','planet')), status TEXT NOT NULL CHECK(status IN ('queued','running','completed','failed','cancelled','interrupted')),
|
||||
phase TEXT NOT NULL DEFAULT 'queued', progress REAL NOT NULL DEFAULT 0,
|
||||
completed_cycles INTEGER NOT NULL DEFAULT 0, total_cycles INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL,
|
||||
error TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, pid INTEGER, log_offset INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_versions (
|
||||
id TEXT PRIMARY KEY CHECK(length(id)=32), owner TEXT NOT NULL, field TEXT NOT NULL,
|
||||
resolution TEXT NOT NULL, name TEXT NOT NULL, rows INTEGER NOT NULL, cols INTEGER NOT NULL,
|
||||
digest TEXT NOT NULL CHECK(length(digest)=64), data BLOB NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS map_versions_owner_resolution ON map_versions(owner,resolution,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS job_maps (
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, field TEXT NOT NULL,
|
||||
version_id TEXT NOT NULL, digest TEXT NOT NULL CHECK(length(digest)=64), data BLOB NOT NULL,
|
||||
PRIMARY KEY(job_id,field)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS jobs_owner_created ON jobs(owner, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS jobs_state_created ON jobs(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS jobs_owner_state ON jobs(owner, status, created_at DESC);
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../lib/labdb.class.php';
|
||||
require_once __DIR__ . '/../lib/job.class.php';
|
||||
require_once __DIR__ . '/../lib/experiment.class.php';
|
||||
require_once __DIR__ . '/../lib/parametercatalog.class.php';
|
||||
|
||||
final class LabWorker {
|
||||
private PDO $db;
|
||||
private array $config;
|
||||
private $lock;
|
||||
private string $activeLog = '';
|
||||
|
||||
public function __construct(?array $config = null) {
|
||||
$this->config = $config ?? lab_config();
|
||||
$this->db = LabDb::connection();
|
||||
}
|
||||
|
||||
public function acquire(): bool {
|
||||
$path = rtrim($this->config['jobs'], '/') . '/.worker.lock';
|
||||
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0750, true) && !is_dir(dirname($path))) return false;
|
||||
$this->lock = fopen($path, 'c');
|
||||
return $this->lock !== false && flock($this->lock, LOCK_EX | LOCK_NB);
|
||||
}
|
||||
|
||||
public function recover(): void {
|
||||
$now = gmdate('c');
|
||||
$this->db->exec("UPDATE jobs SET status='interrupted',phase='interrupted',error=COALESCE(error,'Worker stopped before job completion'),finished_at=" . $this->db->quote($now) . ",updated_at=" . $this->db->quote($now) . ',pid=NULL WHERE status=\'running\'');
|
||||
}
|
||||
|
||||
public function claim(): ?array {
|
||||
$path = $this->config['runtime_lock'];
|
||||
$runtime = fopen($path, is_file($path) ? 'r' : 'c');
|
||||
if ($runtime === false) return null;
|
||||
if (!flock($runtime, LOCK_SH | LOCK_NB)) { fclose($runtime); return null; }
|
||||
try {
|
||||
$this->db->exec('BEGIN IMMEDIATE');
|
||||
if ($this->db->query("SELECT 1 FROM jobs WHERE status='running' LIMIT 1")->fetchColumn()) { $this->db->commit(); return null; }
|
||||
$row = $this->db->query("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at LIMIT 1")->fetch();
|
||||
if (!$row) { $this->db->commit(); return null; }
|
||||
$now = gmdate('c');
|
||||
$s = $this->db->prepare("UPDATE jobs SET status='running',phase='starting',started_at=?,updated_at=? WHERE id=? AND status='queued'");
|
||||
$s->execute([$now, $now, $row['id']]);
|
||||
if (!$s->rowCount()) { $this->db->commit(); return null; }
|
||||
$this->db->commit();
|
||||
try { $row['spec'] = json_decode($row['spec'], true, 512, JSON_THROW_ON_ERROR); }
|
||||
catch (JsonException $e) { $row['spec_error'] = 'Stored job spec is not valid JSON.'; $row['spec'] = null; }
|
||||
return $row;
|
||||
} catch (Throwable $e) { if ($this->db->inTransaction()) $this->db->rollBack(); throw $e; }
|
||||
finally { fclose($runtime); }
|
||||
}
|
||||
|
||||
public function run(array $job): void {
|
||||
if (!empty($job['spec_error'])) { $this->finish($job['id'],'failed',$job['spec_error']); return; }
|
||||
if (!in_array($job['kind'] ?? '', ['model','planet'],true)) { $this->finish($job['id'],'failed','Unsupported job kind.'); return; }
|
||||
$validation = ParameterCatalog::validate($job['spec']);
|
||||
if (empty($validation['ok'])) { $this->finish($job['id'], 'failed', 'Stored job spec failed validation: ' . json_encode($validation['errors'] ?? [])); return; }
|
||||
$job['spec'] = $validation['spec'];
|
||||
$dir = Job::directory(['id' => $job['id']]);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { $this->finish($job['id'], 'failed', 'Could not create the job directory.'); return; }
|
||||
$jobFile = $dir . '/job.json';
|
||||
$captured=Job::validatedMapCaptures($job);
|
||||
if(empty($captured['ok'])){$this->finish($job['id'],'failed',$captured['error']);return;}
|
||||
$mapSnapshot=Maps::writeSnapshots($captured['data'],$dir);
|
||||
if(empty($mapSnapshot['ok'])){$this->finish($job['id'],'failed','Map snapshot is unavailable: '.json_encode($mapSnapshot['errors']??[]));return;}
|
||||
$scientificSpec = $job['spec'];
|
||||
$scientificSpec['parameters'] = (object)$scientificSpec['parameters'];
|
||||
$this->atomicJson($jobFile, ['id'=>$job['id'], 'name'=>$job['name'], 'kind'=>$job['kind'], 'spec'=>$scientificSpec, 'directory'=>$dir, 'map_snapshots'=>$mapSnapshot['data']]);
|
||||
$configFile = getenv('EXOPLASIM_CONFIG') ?: $this->config['project_root'] . '/config/web.json';
|
||||
$configFile = realpath($configFile);
|
||||
if ($configFile === false) { $this->finish($job['id'],'failed','Configuration file does not exist.'); return; }
|
||||
$scriptRoot = $this->config['science_scripts'] ?? ($this->config['project_root'] . '/scripts/web');
|
||||
$script = $scriptRoot . ($job['kind'] === 'planet' ? '/planet.py' : '/model.py');
|
||||
$command = ['setsid', $this->config['python'], $script, '--config', $configFile, '--job', $jobFile];
|
||||
if (!is_file($script)) { $this->finish($job['id'],'failed','Science task script does not exist.'); return; }
|
||||
if (!is_file($this->config['python']) || !is_executable($this->config['python'])) { $this->finish($job['id'],'failed','Configured Python executable is unavailable.'); return; }
|
||||
$logPath = $dir . '/worker.log'; $this->activeLog = $logPath;
|
||||
$log = fopen($logPath, 'ab');
|
||||
if (!$log) { $this->finish($job['id'], 'failed', 'Could not open the job log.'); return; }
|
||||
if (disk_free_space($dir) < (int)$this->config['minimum_free_bytes']) { fclose($log); $this->finish($job['id'], 'failed', 'Insufficient free disk space.'); return; }
|
||||
$pipes = [];
|
||||
$environment=getenv(); if(!is_array($environment))$environment=[]; $environment['EXOPLASIM_CONFIG']=$configFile;
|
||||
$proc = proc_open($command, [0=>['pipe','r'], 1=>['pipe','w'], 2=>['file',$logPath,'a']], $pipes, $this->config['project_root'], $environment);
|
||||
fclose($log);
|
||||
if (!is_resource($proc)) { $this->finish($job['id'], 'failed', 'Could not start the science task.'); return; }
|
||||
fclose($pipes[0]); stream_set_blocking($pipes[1], false);
|
||||
$status = proc_get_status($proc); $pid = (int)$status['pid'];
|
||||
$this->update($job['id'], ['pid'=>$pid,'phase'=>'running']);
|
||||
$started = microtime(true); $buffer = ''; $lastCycle = 0; $datasetCount = 0; $error = null; $cancelled = false; $timedOut = false; $terminationAt = null; $exitCode = null;
|
||||
while (true) {
|
||||
$status = proc_get_status($proc);
|
||||
$chunk = stream_get_contents($pipes[1]);
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
$buffer .= $chunk;
|
||||
try { $this->events($job, $buffer, $lastCycle, $datasetCount, $error); }
|
||||
catch (Throwable $e) { $error=$e->getMessage(); if($terminationAt===null){$terminationAt=microtime(true);$this->signalGroup($pid,SIGTERM);} }
|
||||
}
|
||||
$current = $this->db->prepare('SELECT cancel_requested FROM jobs WHERE id=?'); $current->execute([$job['id']]);
|
||||
if ($error !== null && $terminationAt === null) { $terminationAt=microtime(true); $this->signalGroup($pid,SIGTERM); }
|
||||
if ((int)$current->fetchColumn() && !$cancelled) { $cancelled = true; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); }
|
||||
if (microtime(true) - $started > (int)$this->config['job_timeout_seconds'] && !$timedOut) { $timedOut = true; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); }
|
||||
if (disk_free_space($dir) < (int)$this->config['minimum_free_bytes'] && $error === null) { $error = 'Disk space fell below the configured floor.'; $terminationAt = microtime(true); $this->signalGroup($pid, SIGTERM); }
|
||||
if (!$status['running']) { $exitCode = $status['exitcode']; break; }
|
||||
if ($terminationAt !== null && microtime(true)-$terminationAt > 5) $this->signalGroup($pid, SIGKILL);
|
||||
usleep(200000);
|
||||
}
|
||||
$tail = stream_get_contents($pipes[1]); if ($tail !== false) { $buffer .= $tail; try { $this->events($job, $buffer, $lastCycle, $datasetCount, $error); } catch (Throwable $e) { $error=$e->getMessage(); } }
|
||||
if ($buffer !== '') file_put_contents($logPath,$buffer,FILE_APPEND);
|
||||
fclose($pipes[1]); $closedExit = proc_close($proc); $exit = $exitCode !== null && $exitCode >= 0 ? $exitCode : $closedExit;
|
||||
if ($cancelled) $this->finish($job['id'],'cancelled',null,$lastCycle);
|
||||
elseif ($timedOut) $this->finish($job['id'],'failed','Job exceeded its time limit.',$lastCycle);
|
||||
elseif ($error !== null) $this->finish($job['id'],'failed',$error,$lastCycle);
|
||||
elseif ($exit !== 0) $this->finish($job['id'],'failed',$this->tail($logPath),$lastCycle);
|
||||
elseif ($lastCycle < ($job['kind']==='planet'?1:(int)$job['total_cycles']) || $datasetCount < ($job['kind']==='planet'?1:(int)$job['total_cycles'])) $this->finish($job['id'],'failed','Science task exited without publishing all expected cycle results.',$lastCycle);
|
||||
else $this->finish($job['id'],'completed',null,$job['kind']==='planet'?1:(int)$job['total_cycles']);
|
||||
}
|
||||
|
||||
private function events(array $job, string &$buffer, int &$lastCycle, int &$datasetCount, ?string &$error): void {
|
||||
while (($at = strpos($buffer, "\n")) !== false) {
|
||||
$line = substr($buffer, 0, $at); $buffer = substr($buffer, $at + 1);
|
||||
file_put_contents($this->activeLog,$line."\n",FILE_APPEND);
|
||||
$event = json_decode($line, true); if (!is_array($event)) continue;
|
||||
if (isset($event['error'])) $error = (string)$event['error'];
|
||||
if (isset($event['cycle'])) {
|
||||
$lastCycle = max($lastCycle, (int)$event['cycle']);
|
||||
$total = max(1, (int)($event['cycles'] ?? $job['total_cycles']));
|
||||
$this->update($job['id'], ['completed_cycles'=>$lastCycle,'progress'=>100.0*$lastCycle/$total,'phase'=>isset($event['dataset'])?'postprocessing':'running']);
|
||||
if (isset($event['dataset'])) {
|
||||
if (!empty($event['planet'])) $this->publishPlanet($job,$event);
|
||||
else $this->publishIndex($job, $event);
|
||||
$datasetCount++;
|
||||
}
|
||||
}
|
||||
if (($event['planet'] ?? false) && isset($event['spec'])) $this->saveGeneratedExperiment($job, $event['spec']);
|
||||
}
|
||||
}
|
||||
|
||||
private function publishPlanet(array $job,array $event): void {
|
||||
if(empty($event['dataset'])||!preg_match('/^[a-f0-9]{32}$/',$event['dataset'])) throw new RuntimeException('Planet task returned an invalid dataset ID.');
|
||||
$base=Job::directory(['id'=>$job['id']]).'/artifacts';$path=$base.'/datasets/'.$event['dataset'].'/index.json';
|
||||
$index=json_decode((string)@file_get_contents($path),true);
|
||||
if(!is_array($index)||($index['id']??null)!==$event['dataset']) throw new RuntimeException('Planet dataset index is missing or does not match its ID.');
|
||||
$this->atomicJson($base.'/index.json',['datasets'=>[['id'=>$event['dataset'],'label'=>'Generated planet surface']],'latest'=>$event['dataset']]);
|
||||
}
|
||||
|
||||
private function saveGeneratedExperiment(array $job, array $spec): void {
|
||||
$valid = ParameterCatalog::validate($spec);
|
||||
if (empty($valid['ok'])) throw new RuntimeException('Generated experiment failed validation: ' . json_encode($valid['errors'] ?? []));
|
||||
$saved = Experiment::save($job['owner'], ['name'=>$job['name'],'spec'=>$valid['spec']]);
|
||||
if (empty($saved['ok'])) throw new RuntimeException('Could not save generated experiment.');
|
||||
$s=$this->db->prepare('UPDATE jobs SET experiment_id=? WHERE id=?'); $s->execute([$saved['data']['id'],$job['id']]);
|
||||
}
|
||||
|
||||
private function publishIndex(array $job, array $event): void {
|
||||
if (empty($event['dataset']) || !preg_match('/^[a-f0-9]{32}$/',$event['dataset'])) return;
|
||||
$root=Job::directory(['id'=>$job['id']]) . '/artifacts';
|
||||
$datasetIndex=$root.'/datasets/'.$event['dataset'].'/index.json';
|
||||
$dataset=json_decode((string)@file_get_contents($datasetIndex),true);
|
||||
if (!is_array($dataset)||($dataset['id']??null)!==$event['dataset']) throw new RuntimeException('Science task dataset index is missing or invalid.');
|
||||
$path=$root.'/index.json';
|
||||
$index=is_file($path)?json_decode((string)file_get_contents($path),true):['datasets'=>[],'latest'=>null];
|
||||
$index['datasets'][]=['id'=>$event['dataset'],'label'=>'Cycle '.(int)($event['cycle']??1)]; $index['latest']=$event['dataset'];
|
||||
$this->atomicJson($path,$index);
|
||||
}
|
||||
|
||||
private function signalGroup(int $pid, int $signal): void { if (function_exists('posix_kill')) @posix_kill(-$pid,$signal); }
|
||||
private function update(string $id, array $values): void { $sets=[]; $args=[]; foreach($values as $key=>$value){$sets[]=$key.'=?';$args[]=$value;} $sets[]='updated_at=?';$args[]=gmdate('c');$args[]=$id;$s=$this->db->prepare('UPDATE jobs SET '.implode(',',$sets).' WHERE id=?');$s->execute($args); }
|
||||
private function finish(string $id,string $status,?string $error,?int $cycles=null): void { $s=$this->db->prepare('UPDATE jobs SET status=?,phase=?,progress=CASE WHEN ?=\'completed\' THEN 100 ELSE progress END,completed_cycles=COALESCE(?,completed_cycles),error=?,pid=NULL,finished_at=?,updated_at=? WHERE id=?');$s->execute([$status,$status,$status,$cycles,$error,gmdate('c'),gmdate('c'),$id]); }
|
||||
private function tail(string $path): string { $file=@fopen($path,'rb');if(!$file)return 'Science task failed.';$size=fstat($file)['size']??0;fseek($file,max(0,$size-8192));$data=fread($file,8192);fclose($file);return $data===false?'Science task failed.':$data; }
|
||||
private function atomicJson(string $path,array $data): void { $tmp=$path.'.'.bin2hex(random_bytes(4)).'.tmp';file_put_contents($tmp,json_encode($data,JSON_THROW_ON_ERROR));rename($tmp,$path); }
|
||||
|
||||
public function loop(): void {
|
||||
if (!$this->acquire()) return;
|
||||
$this->recover();
|
||||
while (true) { $job=$this->claim(); if (!$job) { usleep((int)($this->config['poll_seconds']??2)*1000000); continue; } try { $this->run($job); } catch (Throwable $e) { $this->finish($job['id'],'failed',$e->getMessage()); } }
|
||||
}
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) (new LabWorker())->loop();
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
$project_root = dirname(__DIR__);
|
||||
chdir($project_root);
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/web-app-starter/?';
|
||||
$_SERVER['PHP_SELF'] = '/web-app-starter/index.php';
|
||||
$_SERVER['SCRIPT_NAME'] = '/web-app-starter/index.php';
|
||||
$_SERVER['DOCUMENT_URI'] = '/web-app-starter/index.php';
|
||||
$_GET = array();
|
||||
$_POST = array();
|
||||
$_REQUEST = array();
|
||||
$_COOKIE = array();
|
||||
|
||||
require $project_root.'/config/settings.php';
|
||||
require $project_root.'/lib/ulib.php';
|
||||
require $project_root.'/lib/components.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return function($prop) {
|
||||
?>callable fixture <?= safe($prop['id']) ?><?php
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php return [
|
||||
'custom' => function($prop) {
|
||||
return 'custom fixture '.safe($prop['id']);
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
require __DIR__.'/bootstrap.php';
|
||||
$files = [
|
||||
__DIR__.'/../components/exoplasim/workbench.php',
|
||||
__DIR__.'/../views/index.php',
|
||||
__DIR__.'/../themes/exoplasim/page.html.php',
|
||||
];
|
||||
foreach($files as $file) {
|
||||
if(!is_file($file)) { fwrite(STDERR, "Missing UI file: {$file}\n"); exit(1); }
|
||||
exec(escapeshellarg(PHP_BINARY).' -l '.escapeshellarg($file).' 2>&1', $output, $status);
|
||||
if($status !== 0) { fwrite(STDERR, implode("\n", $output)."\n"); exit($status); }
|
||||
$output = [];
|
||||
}
|
||||
$js = file_get_contents(__DIR__.'/../js/workbench.js');
|
||||
foreach(['window.EXOPLASIM','save-run','setDataset','cancel','retry','offset'] as $token) {
|
||||
if(strpos($js, $token) === false) { fwrite(STDERR, "Missing workbench behavior: {$token}\n"); exit(1); }
|
||||
}
|
||||
echo "ExoPlaSim workbench smoke checks passed.\n";
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/bootstrap.php';
|
||||
require_once __DIR__.'/../lib/filebase.class.php';
|
||||
|
||||
function test_fail($message)
|
||||
{
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function test_assert($condition, $message)
|
||||
{
|
||||
if(!$condition)
|
||||
test_fail($message);
|
||||
echo "ok - {$message}\n";
|
||||
}
|
||||
|
||||
function test_throws(callable $fn, $message)
|
||||
{
|
||||
try {
|
||||
$fn();
|
||||
} catch(InvalidArgumentException $e) {
|
||||
echo "ok - {$message}\n";
|
||||
return;
|
||||
}
|
||||
test_fail($message);
|
||||
}
|
||||
|
||||
$root = sys_get_temp_dir().'/web-app-starter-filebase-'.bin2hex(random_bytes(4)).'/';
|
||||
$GLOBALS['config']['filebase']['path'] = $root;
|
||||
|
||||
Filebase::write_data('docs', 'bucket-one/nested', 'meta', array('title' => 'safe needle'));
|
||||
$filename = Filebase::get_data_filename('docs', 'bucket-one/nested', 'meta');
|
||||
test_assert(str_starts_with($filename, $root.'docs/'), 'data filename stays inside configured filebase root');
|
||||
test_assert(Filebase::read_data('docs', 'bucket-one/nested', 'meta')['title'] === 'safe needle', 'nested bucket data round-trips');
|
||||
test_assert(Filebase::list_bucket('docs', 'bucket-one/nested') === array('meta.json'), 'list_bucket uses PHP directory listing');
|
||||
test_assert(Filebase::search_bucket('docs', 'bucket-one/nested', 'needle') === array('meta'), 'search_bucket finds matching JSON without shell grep');
|
||||
|
||||
test_throws(function() { Filebase::get_data_filename('docs', '../escape', 'meta'); }, 'bucket path traversal is rejected');
|
||||
test_throws(function() { Filebase::get_data_filename('../docs', 'bucket', 'meta'); }, 'class path traversal is rejected');
|
||||
test_throws(function() { Filebase::get_data_filename('docs', 'bucket', '../meta'); }, 'type path traversal is rejected');
|
||||
|
||||
$escape_dir = $root.'escape-marker';
|
||||
@mkdir($escape_dir, 0774, true);
|
||||
file_put_contents($escape_dir.'/keep.txt', 'keep');
|
||||
Filebase::delete_bucket('docs', 'bucket-one/nested');
|
||||
test_assert(!file_exists($root.'docs/ne/bucket-one/nested'), 'delete_bucket removes the selected bucket');
|
||||
test_assert(file_exists($escape_dir.'/keep.txt'), 'delete_bucket does not remove sibling paths outside the bucket');
|
||||
|
||||
Filebase::delete_directory($root);
|
||||
echo "All Filebase path guard tests passed.\n";
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/bootstrap.php';
|
||||
|
||||
function test_fail($message)
|
||||
{
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function test_assert($condition, $message)
|
||||
{
|
||||
if(!$condition) test_fail($message);
|
||||
echo "ok - {$message}\n";
|
||||
}
|
||||
|
||||
test_assert(component_exists('callable-fixture'), 'component lookup finds caller-relative components');
|
||||
test_assert(component_exists('multi-fixture'), 'component lookup finds components with named render methods');
|
||||
|
||||
$callable = component('callable-fixture');
|
||||
test_assert(str_contains($callable, 'callable fixture'), 'component renders its default callable');
|
||||
|
||||
$custom = component_call('multi-fixture', 'custom');
|
||||
test_assert(str_contains($custom, 'custom fixture'), 'component_call renders a named method');
|
||||
|
||||
test_assert(safe('<script>') === '<script>', 'safe escapes HTML text');
|
||||
test_assert(asafe('" onload="x') === '" onload="x', 'asafe escapes HTML attributes');
|
||||
|
||||
$head = component_call('theme/shell', 'head', [
|
||||
'extra_css' => ['themes/exoplasim/css/style.css'],
|
||||
'extra_js' => ['js/workbench.js'],
|
||||
]);
|
||||
test_assert(str_contains($head, 'themes/exoplasim/css/style.css'), 'theme shell includes the application stylesheet');
|
||||
test_assert(str_contains($head, 'js/workbench.js'), 'theme shell includes the workbench script');
|
||||
test_assert(!str_contains($head, 'favicon') && !str_contains($head, 'icon.png'), 'theme shell does not reference removed starter icons');
|
||||
|
||||
echo "All smoke tests passed.\n";
|
||||
@@ -0,0 +1 @@
|
||||
<?= URL::$fragments['main'] ?>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
if(URL::$fragments['json'])
|
||||
{
|
||||
print(json_encode(URL::$fragments['json']));
|
||||
}
|
||||
else
|
||||
{
|
||||
print(URL::$fragments['main']);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.surface-map-controls[hidden]{display:none}.surface-map-controls{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin:.8rem 0}.surface-map-controls output{width:100%;font-size:.75rem;color:var(--muted)}
|
||||
.map-editor-dialog{width:min(1100px,94vw);max-height:92vh;padding:2.6rem 1rem 1rem;background:var(--panel,#111719);color:var(--text,#dae0e0);border:1px solid var(--line,#293238);overflow:auto}.map-editor-dialog::backdrop{background:#000b}.map-editor-dialog .dialog-close{position:absolute;right:.5rem;top:.4rem}.map-editor-controls{display:flex;flex-wrap:wrap;align-items:end;gap:.6rem;margin-bottom:.8rem}.map-editor-controls label{display:flex;flex-direction:column;gap:.2rem;font-size:.75rem}.map-editor-controls input,.map-editor-controls select,.map-editor-controls button{background:#0b1012;color:inherit;border:1px solid #34434b;padding:.45rem;font:inherit;min-height:2rem}.map-editor-controls input[type=number]{width:7rem}.map-editor-controls select{max-width:18rem}.map-editor-controls button:disabled{opacity:.45}.map-editor-canvas{display:block;width:100%;height:auto;aspect-ratio:2;touch-action:none;cursor:crosshair;border:1px solid #34434b}.map-editor-canvas:focus-visible{outline:2px solid #c7d96d;outline-offset:2px}.map-editor-scale{display:flex;justify-content:space-between;font-size:.75rem;margin:.4rem 0}.map-editor-dialog output{display:block;font-size:.8rem;margin-top:.3rem;min-height:1.2rem}.map-editor-dialog [data-map-status]{color:#c7d96d}
|
||||
@media(max-width:600px){.map-editor-dialog{width:96vw;padding:2.5rem .6rem .6rem}.map-editor-controls{gap:.4rem}.map-editor-controls label{flex:1 1 42%}.map-editor-controls input,.map-editor-controls select{max-width:100%;width:100%;box-sizing:border-box}.map-editor-controls button{flex:1}.map-editor-controls input[type=number]{width:100%}}
|
||||
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}:root{font-family:Arial,sans-serif;color:#d8e2e6;background:#101619;font-synthesis:none;font-size:13px}body.exo-body{margin:0;min-width:320px;background:#101619;color:#d8e2e6}.exo-workbench{height:100vh;min-height:600px;display:flex;flex-direction:column;overflow:hidden}.exo-toolbar{height:64px;display:flex;align-items:center;gap:26px;padding:0 22px;border-bottom:1px solid #29363a;background:#151d20;flex:none}.exo-brand{display:flex;align-items:center;gap:10px;color:#eef5f5;text-decoration:none;font-weight:700;letter-spacing:.02em;white-space:nowrap}.exo-mark{display:grid;place-items:center;width:28px;height:28px;background:#b8d45a;color:#182020;font-family:Arial,sans-serif;font-size:19px}.exo-toolbar-title{display:flex;align-items:center;gap:12px;min-width:0}.eyebrow,.section-label,.pane-heading,.field-label{font-size:10px;font-weight:700;letter-spacing:.12em;color:#8da0a5}.exo-toolbar-title strong{font-size:14px;font-weight:500;color:#e4ecec;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.save-state{font-size:11px;color:#9eacac}.save-state.dirty{color:#ecc56a}.exo-toolbar-actions{display:flex;align-items:center;gap:8px;margin-left:auto}.exo-toolbar-actions form{margin:0}.button,.icon-button,.text-button{font:inherit;color:inherit;cursor:pointer}.button{border:1px solid #425256;background:#202b2e;padding:8px 12px;border-radius:3px}.button:hover,.icon-button:hover{border-color:#93ad56}.button.primary{background:#b8d45a;border-color:#b8d45a;color:#182020;font-weight:700}.button:disabled,.icon-button:disabled{opacity:.45;cursor:not-allowed}.quiet{background:transparent}.icon-button{background:transparent;border:1px solid #39484c;border-radius:3px;min-width:32px;height:32px}.exo-layout{display:grid;grid-template-columns:230px minmax(360px,1fr) 360px;min-height:0;flex:1}.experiment-pane,.parameter-pane{min-width:0;background:#151d20;overflow:auto}.experiment-pane{padding:16px 13px;border-right:1px solid #29363a}.parameter-pane{padding:16px;border-left:1px solid #29363a}.pane-heading{display:flex;align-items:center;justify-content:space-between;min-height:24px;margin-bottom:12px}.search-box{display:flex;align-items:center;gap:8px;padding:8px 9px;border:1px solid #344448;color:#8da0a5}.search-box input{background:none;border:0;color:inherit;outline:0;width:100%;font:inherit}.experiment-list,.job-list{list-style:none;padding:0;margin:0}.experiment-item{margin:3px 0}.experiment-select{width:100%;text-align:left;background:transparent;border:0;border-left:2px solid transparent;color:#cfdbdc;padding:10px 9px;cursor:pointer;font:inherit}.experiment-select:hover{background:#1d282b}.experiment-select[aria-current=true]{border-left-color:#b8d45a;background:#202b2e}.experiment-select small,.job-item small{display:block;color:#839598;margin-top:5px}.text-button{border:0;background:transparent;color:#a8bd66;padding:6px 3px;font-size:11px}.danger-text{color:#d88977}.results-pane{min-width:0;display:flex;flex-direction:column;padding:12px 15px 14px;overflow:auto}.results-toolbar{height:26px;display:flex;align-items:center;justify-content:space-between;flex:none}.section-label{display:flex;align-items:center;gap:8px}.live-dot{width:6px;height:6px;border-radius:50%;background:#647578}.visualization{position:relative;min-height:260px;flex:1;background:#0d1316;border:1px solid #29363a;overflow:hidden}.empty-results{position:absolute;inset:0;display:grid;place-items:center;color:#859599;text-align:center;padding:25px}.job-pane{height:270px;min-height:210px;flex:none;margin-top:12px;border-top:1px solid #29363a;padding-top:9px;display:flex;flex-direction:column;overflow:hidden}.job-pane .pane-heading{margin-bottom:3px}.job-list{display:flex;gap:5px;overflow:auto;padding:3px 0 8px;flex:none}.job-item{min-width:150px;max-width:210px}.job-select{width:100%;height:100%;padding:8px;text-align:left;border:1px solid #344448;background:#172124;color:#d8e2e6;cursor:pointer;font:inherit}.job-select[aria-current=true]{border-color:#a9c454}.job-status{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#b8d45a}.job-status.failed,.job-status.interrupted{color:#e3937e}.job-status.queued{color:#e9c36a}.log-head{display:flex;align-items:center;gap:10px;padding:6px 0;color:#a8b7b9;font-size:11px;border-top:1px solid #273438}.log-head div{margin-left:auto}.job-error:not([hidden]){display:block;flex:none;max-height:6rem;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#ed9b85;font:11px/1.45 monospace;padding:5px 0}.job-log{margin:0;overflow:auto;flex:1;min-height:55px;background:#0c1214;border:1px solid #263236;color:#a9b8b5;padding:8px;font:11px/1.45 monospace;white-space:pre-wrap}.field-label{display:block;margin:14px 0 6px}.control{width:100%;background:#101719;color:#dce6e7;border:1px solid #35464a;border-radius:2px;padding:8px;font:inherit;outline:none}.control:focus,.search-box:focus-within{border-color:#adc957}.constructor-grid{display:grid;grid-template-columns:1fr 1fr;gap:10pxLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
.viz-root{display:grid;grid-template-rows:auto minmax(0,1fr) auto;height:100%;gap:.75rem;min-width:0;color:inherit}.viz-root [hidden]{display:none!important}.viz-controls select,.viz-controls button{font:inherit;color:inherit;background:#172124;border:1px solid #425256;padding:5px 8px;border-radius:2px}.viz-controls button{cursor:pointer}.viz-controls button:focus-visible,.viz-controls select:focus-visible{outline:2px solid #b8d45a;outline-offset:2px}.viz-controls button:disabled{opacity:.4;cursor:default}.viz-controls input[type=range]{accent-color:#b8d45a;max-width:140px}.viz-controls{font-size:11px}.viz-controls{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.viz-controls label{display:flex;align-items:center;gap:.35rem;min-width:0;max-width:100%}.viz-controls select{min-width:0;max-width:100%}.viz-view{position:relative;min-height:0;background:#101820;overflow:hidden}.viz-view canvas{display:block;width:100%;height:100%;touch-action:none}.viz-view canvas:focus-visible{outline:2px solid #b8d45a;outline-offset:-2px}.viz-globe[hidden],.viz-map[hidden],.viz-empty[hidden],.viz-tooltip[hidden]{display:none}.viz-empty{position:absolute;inset:0;display:grid;place-items:center;background:#101820;color:#d5dde5}.viz-tooltip{position:absolute;pointer-events:none;padding:.3rem .45rem;background:#101820;color:#fff;border:1px solid #52616d;font:12px/1.4 system-ui;white-space:pre-line;max-width:calc(100% - 16px)}.viz-footer{display:grid;grid-template-columns:minmax(10rem,1fr) minmax(12rem,2fr);gap:.5rem;align-items:center}.viz-legend{display:flex;align-items:center;gap:.5rem;font-size:.8rem}.viz-legend div{height:.7rem;flex:1;background:transparent}.viz-inspector{font-size:.85rem;white-space:pre-line}.viz-chart{grid-column:1/-1;width:100%;height:5rem;background:transparent;touch-action:none}.viz-rgb-legend{display:grid;gap:.35rem;font-size:.75rem}.viz-rgb-legend>div{display:grid;grid-template-columns:5rem minmax(2rem,1fr) auto;align-items:center;gap:.5rem}.viz-rgb-legend i{display:block;height:.7rem}@media(max-width:600px){.viz-view{min-height:0}.viz-footer{grid-template-columns:1fr}.viz-chart{grid-column:1}}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
$embed_mode = !empty($_GET['embed']);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en" class="exo-theme">
|
||||
<head>
|
||||
<?= component_call('theme/shell', 'head', ['extra_css' => ['themes/exoplasim/css/style.css', 'themes/exoplasim/css/visualization.css', 'themes/exoplasim/css/map-editor.css'], 'extra_js' => ['js/composite.js', 'js/visualization.js', 'js/map-editor.js', 'js/workbench.js'], 'description' => 'ExoPlaSim model workbench']) ?>
|
||||
</head>
|
||||
<body class="exo-body">
|
||||
<div id="content"><?= URL::$fragments['main'] ?></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
require_once __DIR__.'/../../lib/parametercatalog.class.php';
|
||||
require_once __DIR__.'/../../lib/experiment.class.php';
|
||||
require_once __DIR__.'/../../lib/job.class.php';
|
||||
require_once __DIR__.'/../../lib/results.class.php';
|
||||
require_once __DIR__.'/../../lib/labdb.class.php';
|
||||
require_once __DIR__.'/../../lib/maps.class.php';
|
||||
function api_reply($ok,$data=null,$errors=null,$status=200){ http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($ok?['ok'=>true,'data'=>$data]:array_filter(['ok'=>false,'error'=>is_string($errors)?$errors:'Request failed.','errors'=>is_array($errors)?$errors:null]),JSON_INVALID_UTF8_SUBSTITUTE); }
|
||||
$config=lab_config();
|
||||
$owner=$config['workspace']??'';
|
||||
if (!is_string($owner)||$owner==='') { api_reply(false,null,['workspace'=>'Workspace is not configured.'],503); exit; }
|
||||
$method=$_SERVER['REQUEST_METHOD']??'GET'; $action=is_string($_GET['api']??null)?$_GET['api']:'';
|
||||
$input=$_POST; if ($method==='POST' && str_contains($_SERVER['CONTENT_TYPE']??'','application/json')) { $decoded=json_decode((string)file_get_contents('php://input'),true); if (is_array($decoded)) $input=$decoded; }
|
||||
$id=is_string($_GET['id']??null)?$_GET['id']:''; $result=null;
|
||||
switch($action) {
|
||||
case 'map':
|
||||
if($method!=='GET'){api_reply(false,null,['method'=>'GET required.'],405);exit;}
|
||||
$map=Maps::get($id);if(!$map){api_reply(false,null,['id'=>'Map version not found.'],404);exit;}
|
||||
$catalog=MapCatalog::schema();$metadata=['id'=>$map['id'],'field'=>$map['field'],'resolution'=>$map['resolution'],'name'=>$map['name'],'digest'=>$map['digest'],'field_metadata'=>$catalog['fields'][$map['field']]];
|
||||
header('Content-Type: application/octet-stream');header('X-Map-Shape: '.$map['rows'].','.$map['cols']);header('X-Map-Metadata: '.base64_encode(json_encode($metadata)));echo $map['data'];exit;
|
||||
case 'maps':
|
||||
if($method==='GET'){$result=Maps::all((string)($_GET['resolution']??''));if(!empty($result['ok'])){$result['data']['fields']=ParameterCatalog::schema()['map_fields']??$result['data']['fields'];$result['data']['grid']=ParameterCatalog::schema()['map_grids'][$_GET['resolution']]??$result['data']['grid'];}api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200);exit;}
|
||||
if($method!=='POST'){api_reply(false,null,['method'=>'GET or POST required.'],405);exit;}
|
||||
if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','application/octet-stream')){api_reply(false,null,['content_type'=>'Use application/octet-stream.'],415);exit;}
|
||||
$result=Maps::save((string)($_GET['field']??''),(string)($_GET['resolution']??''),(string)($_GET['name']??''),(string)file_get_contents('php://input'));api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:201);exit;
|
||||
case 'schema': $result=ParameterCatalog::schema(); if(!$result) api_reply(false,null,['schema'=>'Parameter catalog unavailable.'],503); else api_reply(true,$result); exit;
|
||||
case 'experiments': if($method==='GET') api_reply(true,Experiment::all($owner)); else { $result=Experiment::save($owner,$input); api_reply($result['ok'],$result['data']??null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); } exit;
|
||||
case 'experiment_delete': $result=Experiment::delete($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
|
||||
case 'jobs': if($method==='GET') api_reply(true,Job::all($owner)); else { $e=Experiment::get($input['experiment_id']??'',$owner); if(!$e) api_reply(false,null,['experiment'=>'Experiment not found.'],404); else { $result=Job::enqueue($owner,$e); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); } } exit;
|
||||
case 'generate': $spec=$input['spec']??null; $result=Job::enqueueKind($owner,'planet',$input['name']??'Generated planet',$spec); api_reply($result['ok'],isset($result['data'])?['job'=>$result['data']]:null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); exit;
|
||||
case 'cancel': $result=Job::cancel($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
|
||||
case 'retry': $result=Job::retry($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); exit;
|
||||
case 'log': $result=Job::log($id,$owner,$_GET['offset']??0); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit;
|
||||
case 'results': case 'series': case 'frame': case 'download': api_artifacts($action,$id,$owner); exit;
|
||||
default: api_reply(false,null,['route'=>'Not found.'],404); exit;
|
||||
}
|
||||
function owned_job($id,$owner){ return is_string($id)&&preg_match('/^[a-f0-9]{32}$/',$id)?Job::get($id,$owner):null; }
|
||||
function api_artifact_path(array $job,string $path): ?string {
|
||||
$root=realpath(Job::directory($job));$file=realpath($path);
|
||||
return $root!==false&&$file!==false&&is_file($file)&&is_readable($file)&&str_starts_with($file,$root.DIRECTORY_SEPARATOR)?$file:null;
|
||||
}
|
||||
function api_artifact_limit(): int { return max(0,(int)(lab_config()['max_result_bytes']??0)); }
|
||||
function api_artifact_available(array $job,string $path): bool {
|
||||
$file=api_artifact_path($job,$path);$size=$file===null?false:filesize($file);
|
||||
return $size!==false&&$size<=api_artifact_limit();
|
||||
}
|
||||
function api_send_artifact(array $job,string $path,string $name): void {
|
||||
$file=api_artifact_path($job,$path);
|
||||
if($file===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;}
|
||||
$stream=@fopen($file,'rb');$stat=$stream===false?false:fstat($stream);$limit=api_artifact_limit();
|
||||
if($stat===false||$stat['size']>$limit){if($stream!==false)fclose($stream);api_reply(false,null,['artifact'=>'Artifact exceeds the download size limit.'],413);return;}
|
||||
$name=basename($name);header('Content-Type: application/octet-stream');header('Content-Disposition: attachment; filename="'.$name.'"');header('Content-Length: '.$stat['size']);
|
||||
$remaining=$stat['size'];while($remaining>0&&!feof($stream)){$chunk=fread($stream,min(1048576,$remaining));if($chunk===false||$chunk==='')break;echo $chunk;$remaining-=strlen($chunk);}fclose($stream);
|
||||
}
|
||||
function api_downloads(array $job,array $manifest,array $selected): array {
|
||||
$root=Job::directory($job);$artifacts=$root.'/artifacts';$links=[];
|
||||
$add=static function(string $id,string $label,string $path)use(&$links,$job):void{if(api_artifact_available($job,$path))$links[]=['id'=>$id,'label'=>$label];};
|
||||
$add('log','Run log',$root.'/worker.log');
|
||||
foreach($job['spec']['maps']??[] as $field=>$version){$meta=ParameterCatalog::schema()['map_fields'][$field]??null;if(!$meta)continue;$add('input-map:'.$field,'Input map · '.$meta['label'],$root.'/inputs/maps/'.$field.'.f32');$add('native-input:'.$field,'Native input · '.$meta['label'],$root.'/inputs/native/'.$field.'.sra');}
|
||||
if(($job['kind']??'model')==='model'){
|
||||
$add('model-config','Model configuration',$root.'/runtime/GUI.cfg');
|
||||
$add('restart:plasim_restart','Model restart',$root.'/runtime/plasim_restart');
|
||||
foreach(glob($root.'/runtime/MOST_REST*')?:[] as $path){$name=basename($path);if(preg_match('/^MOST_REST(?:\\.[0-9]{5})?$/',$name))$add('restart:'.$name,$name,$path);}
|
||||
}
|
||||
if(($job['kind']??'')==='planet'){
|
||||
$add('terrain-recipe','Terrain recipe',$artifacts.'/planet.json');
|
||||
foreach(glob($artifacts.'/planet-work/N???_surf_*.sra')?:[] as $path){$name=basename($path);if(preg_match('/^N[0-9]{3}_surf_(0129|0172)\.sra$/',$name))$add('sra:'.$name,$name,$path);}
|
||||
}
|
||||
foreach($manifest['datasets']??[] as $position=>$item){
|
||||
$datasetId=$item['id']??'';if($datasetId!==($selected['id']??null))continue;$index=$selected;
|
||||
$add('dataset-index:'.$datasetId,'Dataset index · '.($item['label']??'Dataset'),$artifacts.'/datasets/'.$datasetId.'/index.json');
|
||||
foreach($index['fields']??[] as $field){if(!is_array($field))continue;$name=$field['name']??'';if(preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$name))$add('field:'.$datasetId.':'.$name,($item['label']??'Dataset').' · '.($field['label']??$name),$artifacts.'/datasets/'.$datasetId.'/'.$name.'.f32');}
|
||||
if(($job['kind']??'model')==='model'){
|
||||
$raw='MOST.'.sprintf('%05d',$position);$add('raw:'.$datasetId,'Raw model output · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'.npz');
|
||||
$add('raw-metadata:'.$datasetId,'Raw model metadata · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'_metadata.npz');
|
||||
}
|
||||
if(($job['kind']??'')==='planet')foreach(glob($artifacts.'/planet-work/surface-*.npz')?:[] as $path){$name=basename($path);if(preg_match('/^(surface-[a-f0-9]{32})\.npz$/',$name,$parts)){$add('raw:'.$datasetId.':'.$name,'Raw terrain preview',$path);$add('raw-metadata:'.$datasetId.':'.$parts[1],'Raw terrain metadata',$artifacts.'/planet-work/'.$parts[1].'_metadata.npz');}}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
function api_artifacts(string $route,$id,$owner): void {
|
||||
$job=owned_job($id,$owner);if(!$job){api_reply(false,null,['id'=>'Job not found.'],404);return;}
|
||||
$root=Job::directory($job);$base=$root.'/artifacts';$manifest=Results::manifest($job)??['datasets'=>[],'latest'=>null];
|
||||
if($route==='download'){
|
||||
$artifact=is_string($_GET['artifact']??null)?$_GET['artifact']:'';$path=null;$name='';
|
||||
if($artifact==='log'){$path=$root.'/worker.log';$name='worker.log';}
|
||||
elseif(preg_match('/^(input-map|native-input):([a-z][a-z0-9_]*)$/',$artifact,$match)&&isset($job['spec']['maps'][$match[2]],ParameterCatalog::schema()['map_fields'][$match[2]])){$native=$match[1]==='native-input';$name=$match[2].($native?'.sra':'.f32');$path=$root.'/inputs/'.($native?'native':'maps').'/'.$name;}
|
||||
elseif($artifact==='model-config'&&($job['kind']??'model')==='model'){$path=$root.'/runtime/GUI.cfg';$name='GUI.cfg';}
|
||||
elseif($artifact==='terrain-recipe'&&($job['kind']??'')==='planet'){$path=$base.'/planet.json';$name='planet.json';}
|
||||
elseif(preg_match('/^dataset-index:([a-f0-9]{32})$/',$artifact,$match)&&Results::dataset($job,$match[1])){$path=$base.'/datasets/'.$match[1].'/index.json';$name='index.json';}
|
||||
elseif(preg_match('/^field:([a-f0-9]{32}):([A-Za-z][A-Za-z0-9_]*)$/',$artifact,$match)){$index=Results::dataset($job,$match[1]);foreach($index['fields']??[] as $field)if(($field['name']??null)===$match[2]){$path=$base.'/datasets/'.$match[1].'/'.$match[2].'.f32';$name=$match[2].'.f32';break;}}
|
||||
elseif(preg_match('/^raw:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}\.npz))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){
|
||||
if(($job['kind']??'model')==='model'&&empty($match[2])){
|
||||
foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$path=$root.'/runtime/'.sprintf('MOST.%05d.npz',$position);$name=basename($path);break;}
|
||||
}elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2];$path=$base.'/planet-work/'.$name;}
|
||||
}
|
||||
elseif(preg_match('/^raw-metadata:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){
|
||||
if(($job['kind']??'model')==='model'&&empty($match[2])){
|
||||
foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$name='MOST.'.sprintf('%05d',$position).'_metadata.npz';$path=$root.'/runtime/'.$name;break;}
|
||||
}elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2].'_metadata.npz';$path=$base.'/planet-work/'.$name;}
|
||||
}
|
||||
elseif(preg_match('/^restart:(plasim_restart|MOST_REST(?:\.[0-9]{5})?)$/',$artifact,$match)&&($job['kind']??'model')==='model'){$name=$match[1];$path=$root.'/runtime/'.$name;}
|
||||
elseif(preg_match('/^sra:(N[0-9]{3}_surf_(?:0129|0172)\.sra)$/',$artifact,$match)&&($job['kind']??'')==='planet'){$name=$match[1];$path=$base.'/planet-work/'.$name;}
|
||||
if($path===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;}
|
||||
api_send_artifact($job,$path,$name);return;
|
||||
}
|
||||
if($route==='results'){
|
||||
$dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset);
|
||||
if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;}
|
||||
api_reply(true,['index'=>$index,'dataset'=>$index,'datasets'=>$manifest['datasets'],'latest'=>$manifest['latest']??null,'downloads'=>api_downloads($job,$manifest,$index)]);return;
|
||||
}
|
||||
$dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset);
|
||||
if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;}
|
||||
$field=is_string($_GET['field']??null)?$_GET['field']:'';$meta=null;
|
||||
foreach($index['fields']??[] as $candidate)if(($candidate['name']??null)===$field)$meta=$candidate;
|
||||
if(!$meta||!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$field)){api_reply(false,null,['field'=>'Field not found.'],404);return;}
|
||||
$shape=$meta['shape']??[];$level=filter_var($_GET['level']??0,FILTER_VALIDATE_INT);$time=filter_var($_GET['time']??0,FILTER_VALIDATE_INT);
|
||||
if(!is_array($shape)||$level===false||$time===false||$level<0||$time<0||$level>=(int)($shape[1]??0)||$time>=(int)($shape[0]??0)){api_reply(false,null,['selection'=>'Selection out of range.'],416);return;}
|
||||
$dir=$base.'/datasets/'.$dataset;
|
||||
if($route==='series'){$stats=json_decode((string)@file_get_contents($dir.'/'.$field.'.json'),true)??[];api_reply(true,['time'=>$index['time']??[],'time_unit'=>$index['time_unit']??'','mean'=>array_column($stats['mean']??[],$level),'min'=>array_column($stats['min']??[],$level),'max'=>array_column($stats['max']??[],$level),'unit'=>$meta['unit']??'']);return;}
|
||||
if($route==='frame'){
|
||||
$lat=count($index['lat']??[]);$lon=count($index['lon']??[]);$count=$lat*$lon;$bytes=$count*4;$limit=max(0,(int)(lab_config()['max_field_memory_bytes']??8388608));$file=api_artifact_path($job,$dir.'/'.$field.'.f32');$stream=$file===null?false:@fopen($file,'rb');
|
||||
$offset=(($time*(int)$shape[1]+$level)*$count)*4;
|
||||
if(!$stream||$count<1||$bytes>$limit||fseek($stream,$offset)!==0){if($stream)fclose($stream);api_reply(false,null,['frame'=>'Frame unavailable.'],404);return;}
|
||||
$data=fread($stream,$bytes);fclose($stream);if($data===false||strlen($data)!==$bytes){api_reply(false,null,['frame'=>'Frame is incomplete.'],404);return;}
|
||||
header('Content-Type: application/octet-stream');header('X-Frame-Shape: '.$lat.','.$lon);echo $data;return;
|
||||
}
|
||||
api_reply(false,null,['route'=>'Not found.'],404);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php return(function($prop) {
|
||||
|
||||
?><div class="banner error"><?= safe($prop['text']) ?></div><?php
|
||||
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
$apiNames = ['schema', 'experiments', 'experiment_delete', 'generate', 'jobs', 'log', 'cancel', 'retry', 'results', 'series', 'frame', 'download', 'maps', 'map'];
|
||||
$api = [];
|
||||
foreach($apiNames as $name) $api[$name] = '?' . http_build_query(['api' => $name]);
|
||||
$bootstrap = [
|
||||
'api' => $api,
|
||||
];
|
||||
?>
|
||||
<script>window.EXOPLASIM = <?= json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
|
||||
<?= component('components/exoplasim/workbench', ['bootstrap' => $bootstrap]) ?>
|
||||
Reference in new issue
Block a user