Add private PHP scientific workbench and native map editor

This commit is contained in:
udo committed 2026-10-11 00:31:43 +00:00
1 parent be871f1dd9
commit 628824407f
79 files changed
+5493 -1

No files matched your search

+66
View File
@@ -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
+15
View File
@@ -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.
+7
View File
@@ -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.
+72
View File
@@ -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 &amp; 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();
},
];
+80
View File
@@ -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();
},
];
+18
View File
@@ -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', '');
+41
View File
@@ -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);
+161
View File
@@ -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();};
};
})();
+1
View File
@@ -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
+65
View File
@@ -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);
+137
View File
@@ -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
```
+589
View File
@@ -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
};
}));
+176
View File
@@ -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='';} };
};
})();
+85
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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&&current===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();
})();
+262
View File
@@ -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);
}
+26
View File
@@ -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.']); }
}
+286
View File
@@ -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);
}
}
}
+39
View File
@@ -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]]; }
}
+20
View File
@@ -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;
}
}
+41
View File
@@ -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));
}
}
+10
View File
@@ -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;
}
}
+40
View File
@@ -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];
}
}
+50
View File
@@ -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];
}
}
+42
View File
@@ -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);
}
}
+14
View File
@@ -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;
}
}
+271
View File
@@ -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));
}
+14
View File
@@ -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 : '');
}
}
+28
View File
@@ -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);
+178
View File
@@ -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();
+18
View File
@@ -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
};
+5
View File
@@ -0,0 +1,5 @@
<?php return [
'custom' => function($prop) {
return 'custom fixture '.safe($prop['id']);
},
];
+18
View File
@@ -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";
+52
View File
@@ -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";
+37
View File
@@ -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>') === '&lt;script&gt;', 'safe escapes HTML text');
test_assert(asafe('" onload="x') === '&quot; onload=&quot;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";
+1
View File
@@ -0,0 +1 @@
<?= URL::$fragments['main'] ?>
+12
View File
@@ -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']);
}
+3
View File
@@ -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%}}
+1
View File
@@ -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}}
+12
View File
@@ -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>
+129
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
<?php return(function($prop) {
?><div class="banner error"><?= safe($prop['text']) ?></div><?php
});
+10
View File
@@ -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]) ?>