@homie-rocks/studio 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +24 -3
  2. package/docs/toolkit-place-patterns.md +16 -0
  3. package/lib/template-history.json +42 -0
  4. package/package.json +1 -1
  5. package/parts/README.md +28 -0
  6. package/parts/catalogue.json +236 -0
  7. package/parts/owner-action-checklist/LICENSE +202 -0
  8. package/parts/owner-action-checklist/NOTICE +3 -0
  9. package/parts/owner-action-checklist/README.md +68 -0
  10. package/parts/owner-action-checklist/part.json +95 -0
  11. package/parts/owner-action-checklist/preview/index.html +16 -0
  12. package/parts/owner-action-checklist/src/handler.mjs +44 -0
  13. package/parts/owner-action-checklist/src/index.mjs +96 -0
  14. package/parts/owner-action-checklist/src/style.css +3 -0
  15. package/parts/owner-action-checklist/tuning.json +3 -0
  16. package/parts/terrain-route-map/LICENSE +202 -0
  17. package/parts/terrain-route-map/NOTICE +3 -0
  18. package/parts/terrain-route-map/README.md +67 -0
  19. package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
  20. package/parts/terrain-route-map/part.json +133 -0
  21. package/parts/terrain-route-map/preview/demo.js +4127 -0
  22. package/parts/terrain-route-map/preview/heightmap.png +0 -0
  23. package/parts/terrain-route-map/preview/index.html +1 -0
  24. package/parts/terrain-route-map/preview/main.mjs +4 -0
  25. package/parts/terrain-route-map/preview/route.json +1 -0
  26. package/parts/terrain-route-map/src/data.mjs +81 -0
  27. package/parts/terrain-route-map/src/index.mjs +131 -0
  28. package/parts/terrain-route-map/src/style.css +3 -0
  29. package/parts/terrain-route-map/tuning.json +5 -0
  30. package/tools/tools.ts +1 -1
  31. package/worker/booking-calendar.mjs +15 -2
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/version.mjs +1 -1
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Terrain route map</title><link rel="stylesheet" href="../src/style.css"><style>body{margin:0;background:#091e28;color:#f0eedf;font-family:system-ui;padding:20px}header{display:flex;justify-content:space-between;align-items:end;gap:20px;max-width:1800px;margin:0 auto 20px}small{color:#f4d47b;letter-spacing:.14em}h1{font-size:clamp(2rem,5vw,3.5rem);letter-spacing:-.05em;margin:8px 0}p{color:#a7c0c7;margin:0;max-width:28rem}#map{max-width:1800px;margin:auto;height:70vh;min-height:440px}@media(max-width:600px){header{display:block}#map{height:65vh}}</style><header><div><small>FIELD ATLAS / 01</small><h1>The coastal path.</h1></div><p>From sheltered harbour to high ridge. Drag to explore, pinch to zoom, or use the camera buttons.</p></header><main id="map" aria-label="Coastal path terrain"></main><script type="module" src="./demo.js"></script></html>
@@ -0,0 +1,4 @@
1
+ import {mountTerrainMap} from '../src/index.mjs';
2
+ const data=await (await fetch(new URL('./route.json',import.meta.url))).json();
3
+ data.heightmap=new URL('./heightmap.png',import.meta.url).href;
4
+ try { window.map=await mountTerrainMap(document.querySelector('main'),data); } catch(error) { document.querySelector('main').textContent=`Map could not load: ${error.message}`; throw error; }
@@ -0,0 +1 @@
1
+ {"bounds":[0,0,100,100],"height":42,"seaLevel":6,"heightmap":"heightmap.png","route":{"type":"LineString","coordinates":[[20,72],[30,66],[40,58],[48,51],[58,45],[67,36],[75,26]]},"markers":[{"coordinates":[20,72],"label":"Harbour"},{"coordinates":[48,51],"label":"Lookout"},{"coordinates":[75,26],"label":"High ridge"}]}
@@ -0,0 +1,81 @@
1
+ export function validateMap(data, {baseURL = globalThis.location?.href, allowedOrigins = []} = {}) {
2
+ validateHeightmapURL(data.heightmap, {baseURL, allowedOrigins});
3
+ const {bounds, route, markers = [], height = 28, seaLevel = 6} = data;
4
+ if (!Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) throw new Error('bounds must be [minX,minZ,maxX,maxZ]');
5
+ const point = p => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite) && p[0]>=bounds[0] && p[0]<=bounds[2] && p[1]>=bounds[1] && p[1]<=bounds[3];
6
+ if (route?.type !== 'LineString' || !Array.isArray(route.coordinates) || route.coordinates.length < 2 || route.coordinates.length > 2048 || !route.coordinates.every(point)) throw new Error('route must be a bounded LineString of 2–2048 points');
7
+ if (route.coordinates.some((p,i,a)=> i && p[0]===a[i-1][0] && p[1]===a[i-1][1])) throw new Error('Consecutive route points must differ');
8
+ if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(seaLevel) || seaLevel < 0 || seaLevel >= height) throw new Error('Invalid height or sea level');
9
+ if (!Array.isArray(markers) || markers.length > 32 || markers.some(m=>!point(m.coordinates)||typeof m.label!=='string'||!m.label.trim()||m.label.length>80)) throw new Error('Invalid markers (maximum 32)');
10
+ return {...data, markers, height, seaLevel};
11
+ }
12
+ /** Red channel, row zero = minZ. Bilinear interpolation; pixel endpoints span bounds. */
13
+ export function heightSampler({width, height, data}, bounds, amplitude) {
14
+ if (!Number.isInteger(width)||!Number.isInteger(height)||width<2||height<2||data.length!==width*height*4) throw new Error('Invalid RGBA heightmap');
15
+ return (x,z) => {
16
+ const u=Math.max(0,Math.min(1,(x-bounds[0])/(bounds[2]-bounds[0])))*(width-1);
17
+ const v=Math.max(0,Math.min(1,(z-bounds[1])/(bounds[3]-bounds[1])))*(height-1);
18
+ const a=Math.floor(u), b=Math.floor(v), fx=u-a, fz=v-b;
19
+ const sample=(i,j)=>data[(Math.min(j,height-1)*width+Math.min(i,width-1))*4]/255*amplitude;
20
+ return (sample(a,b)*(1-fx)+sample(a+1,b)*fx)*(1-fz)+(sample(a,b+1)*(1-fx)+sample(a+1,b+1)*fx)*fz;
21
+ };
22
+ }
23
+
24
+ /** Stable screen-space packing. Hidden overflow remains in the accessible marker list. */
25
+ export function placeLabels(points, width, height) {
26
+ const placed=[];
27
+ return points.map(({x,y,w,h})=>{
28
+ const left=Math.max(8,Math.min(width-w-8,x-w/2));
29
+ for(let step=0;step<height;step+=h+8){
30
+ for(const sign of [-1,1]){
31
+ const top=Math.max(8,Math.min(height-h-112,y-72+step*sign));
32
+ if(top<8 || placed.some(b=>left<b.x+b.w+8&&left+w+8>b.x&&top<b.y+b.h+8&&top+h+8>b.y))continue;
33
+ const box={x:left,y:top,w,h};placed.push(box);return box;
34
+ }
35
+ }
36
+ return null;
37
+ });
38
+ }
39
+ /** Ignore startup/hidden gaps; sustained slow frames reduce the GPU budget once. */
40
+ export function qualityMonitor({threshold=28,samples=90}={}) {
41
+ let count=0,slow=0;
42
+ return dt=>{if(dt<=0||dt>250)return false;count++;if(dt>threshold)slow++;return count>=samples&&slow/count>.25;};
43
+ }
44
+
45
+ /** PNG only: verify signature and IHDR dimensions before invoking an image decoder. */
46
+ export function heightmapDimensions(bytes) {
47
+ const signature = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82];
48
+ if (bytes.length < 24 || signature.some((value,i) => bytes[i] !== value)) throw new Error('Expected a PNG heightmap');
49
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
50
+ const width = view.getUint32(16), height = view.getUint32(20);
51
+ if (width < 2 || height < 2 || width > 2048 || height > 2048) throw new Error('Heightmap dimensions must be 2–2048 pixels');
52
+ return {width, height};
53
+ }
54
+ export function validateHeightmapURL(value, {baseURL, allowedOrigins = []} = {}) {
55
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid heightmap URL');
56
+ // Without a browser origin, validation permits relative paths only by default.
57
+ const base = new URL(baseURL || 'https://relative.invalid/');
58
+ const url = new URL(value, base);
59
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !/\.png$/i.test(url.pathname) ||
60
+ (url.origin !== base.origin && !allowedOrigins.includes(url.origin)) ||
61
+ (!baseURL && /^(?:[a-z][a-z0-9+.-]*:|[\/\\]{2})/i.test(value) && !allowedOrigins.includes(url.origin))) throw new Error('Heightmap must be a same-origin or explicitly allowed PNG URL');
62
+ return url.href;
63
+ }
64
+ export async function loadHeightmap(value, options = {}) {
65
+ const url = validateHeightmapURL(value, options);
66
+ const response = await fetch(url, {redirect:'error', credentials:'same-origin'});
67
+ if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim() !== 'image/png') throw new Error('Expected a PNG response');
68
+ const reader = response.body.getReader(), chunks = []; let size = 0;
69
+ try {
70
+ for (;;) {
71
+ const {done, value:chunk} = await reader.read(); if (done) break;
72
+ size += chunk.byteLength;
73
+ if (size > 16 * 1024 * 1024) throw new Error('Heightmap exceeds download budget');
74
+ chunks.push(chunk);
75
+ }
76
+ } catch (error) {await reader.cancel(); throw error;}
77
+ const bytes = new Uint8Array(size); let offset = 0;
78
+ for (const chunk of chunks) {bytes.set(chunk,offset); offset += chunk.length;}
79
+ const {width,height} = heightmapDimensions(bytes);
80
+ return createImageBitmap(new Blob([bytes], {type:'image/png'}), {resizeWidth:width, resizeHeight:height});
81
+ }
@@ -0,0 +1,131 @@
1
+ import * as THREE from 'three';
2
+ import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
3
+ import {validateMap, loadHeightmap, heightSampler, placeLabels, qualityMonitor} from './data.mjs';
4
+ /** Owns only its canvas, controls and GPU resources. Call dispose when leaving the view. */
5
+ export async function mountTerrainMap(root, input, {segments=192, pixelRatio=1.5, quality='auto', allowedOrigins=[]}={}) {
6
+ if(!['auto','full','lite'].includes(quality))throw new Error('Invalid quality');
7
+ const imageOptions={baseURL:location.href, allowedOrigins};
8
+ const data=validateMap(input,imageOptions);
9
+ if (!Number.isInteger(segments)||segments<8||segments>256||!Number.isFinite(pixelRatio)||pixelRatio<.5||pixelRatio>2) throw new Error('Invalid rendering budget');
10
+ const spanBudget=Math.max(data.bounds[2]-data.bounds[0],data.bounds[3]-data.bounds[1]);
11
+ const estimatedPoints=data.route.coordinates.slice(1).reduce((sum,b,i)=>{const a=data.route.coordinates[i];return sum+Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(spanBudget/segments/2)));},1);
12
+ if(estimatedPoints>8192) throw new Error('Route exceeds rendering budget');
13
+ const image = await loadHeightmap(data.heightmap,imageOptions);
14
+ const sampling=document.createElement('canvas'); sampling.width=image.width; sampling.height=image.height;
15
+ const context=sampling.getContext('2d',{willReadFrequently:true}); context.drawImage(image,0,0);
16
+ image.close();
17
+ const rawSample=heightSampler(context.getImageData(0,0,image.width,image.height),data.bounds,data.height);
18
+ const sample=(x,z)=>(rawSample(x,z)*4+rawSample(x-.5,z)+rawSample(x+.5,z)+rawSample(x,z-.5)+rawSample(x,z+.5))/8;
19
+ const [x0,z0,x1,z1]=data.bounds, width=x1-x0, depth=z1-z0, span=Math.max(width,depth);
20
+ const scene=new THREE.Scene(); scene.background=new THREE.Color('#9ebbc5'); scene.fog=new THREE.FogExp2('#9ebbc5',.0009);
21
+ const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
22
+ let lite=quality==='lite', intro=!reduced; const monitor=qualityMonitor();
23
+ const camera=new THREE.PerspectiveCamera(40,1,span/1000,span*12);
24
+ const renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); renderer.setPixelRatio(Math.min(devicePixelRatio,pixelRatio));
25
+ renderer.outputColorSpace=THREE.SRGBColorSpace; renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.15;
26
+ const canvas=renderer.domElement; canvas.tabIndex=0; canvas.setAttribute('role','img'); canvas.setAttribute('aria-label','Terrain map. Drag to orbit, pinch to zoom. Arrow keys rotate, plus and minus zoom, Home resets.');
27
+ root.classList.add('terrain-map'); root.append(canvas);
28
+ const controls=new OrbitControls(camera,canvas); controls.enableDamping=false; controls.enablePan=false; controls.minDistance=span*.3; controls.maxDistance=span*3; controls.maxPolarAngle=Math.PI*.46;
29
+ controls.target.set((x0+x1)/2,data.seaLevel,(z0+z1)/2);
30
+ function reset(){const fit=Math.max(1,1/Math.max(.35,camera.aspect));camera.position.set((x0+x1)/2+span*.48*fit,span*.65*fit,(z0+z1)/2+span*.78*fit); controls.update();}
31
+ reset();
32
+ scene.add(new THREE.HemisphereLight('#bce2ff','#293d32',1.7));
33
+ const sun=new THREE.DirectionalLight('#ffe2ad',3.1); sun.position.set(-span,span*.9,span); scene.add(sun);
34
+ const sky=new THREE.Mesh(new THREE.SphereGeometry(span*5,16,12),new THREE.ShaderMaterial({side:THREE.BackSide,depthWrite:false,uniforms:{},vertexShader:`varying vec3 direction;void main(){direction=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,fragmentShader:`varying vec3 direction;void main(){float h=smoothstep(-.1,.7,normalize(direction).y);gl_FragColor=vec4(mix(vec3(.62,.73,.77),vec3(.12,.32,.49),h),1.);}`}));sky.position.copy(controls.target);scene.add(sky);
35
+ const geometry=new THREE.PlaneGeometry(width,depth,segments,segments); geometry.rotateX(-Math.PI/2); geometry.translate((x0+x1)/2,0,(z0+z1)/2);
36
+ const positions=geometry.attributes.position; const colors=[];
37
+ for(let i=0;i<positions.count;i++) {
38
+ const y=sample(positions.getX(i),positions.getZ(i)); positions.setY(i,y);
39
+ const x=positions.getX(i),z=positions.getZ(i);
40
+ const slope=Math.hypot(sample(x+1,z)-sample(x-1,z),sample(x,z+1)-sample(x,z-1));
41
+ const color=new THREE.Color('#3e6951').lerp(new THREE.Color('#a2a493'),Math.min(1,Math.max(slope*.35,(y/data.height-.5)*2)));
42
+ color.lerp(new THREE.Color('#d7c99d'),1-THREE.MathUtils.smoothstep(y,data.seaLevel+.2,data.seaLevel+2.5));
43
+ const cavity=Math.max(0,(sample(x+3,z)+sample(x-3,z)+sample(x,z+3)+sample(x,z-3))/4-y);
44
+ color.multiplyScalar((.96+.04*Math.sin(x*.7+Math.sin(z*.3))*Math.cos(z*.8))*(1-Math.min(.3,cavity*.16))); colors.push(color.r,color.g,color.b);
45
+ }
46
+ geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,3)); geometry.computeVertexNormals();
47
+ function useLite(){lite=true;renderer.setPixelRatio(Math.min(1,pixelRatio));renderer.setSize(root.clientWidth,root.clientHeight,false);
48
+ // Reuse the vertex buffer, drawing every second grid cell. No second mesh allocation.
49
+ if(segments%2===0){const indices=[];for(let z=0;z<segments;z+=2)for(let x=0;x<segments;x+=2){const a=z*(segments+1)+x,b=a+2,c=a+2*(segments+1),d=c+2;indices.push(a,c,b,b,c,d);}geometry.setIndex(indices);}
50
+ }
51
+ if(lite)useLite();
52
+ scene.add(new THREE.Mesh(geometry,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,flatShading:false})));
53
+ const heightTexture=new THREE.CanvasTexture(sampling); heightTexture.flipY=false;
54
+ const waterMaterial=new THREE.ShaderMaterial({uniforms:{time:{value:0},heights:{value:heightTexture},bounds:{value:new THREE.Vector4(x0,z0,width,depth)},amplitude:{value:data.height},sea:{value:data.seaLevel},lite:{value:lite?1:0}},
55
+ vertexShader:`varying vec3 world; void main(){world=(modelMatrix*vec4(position,1.)).xyz;gl_Position=projectionMatrix*viewMatrix*vec4(world,1.);}`,
56
+ fragmentShader:`precision highp float; varying vec3 world; uniform sampler2D heights; uniform vec4 bounds; uniform float amplitude,sea,time,lite;
57
+ void main(){vec2 uv=(world.xz-bounds.xy)/bounds.zw;float inside=smoothstep(0.,.08,uv.x)*smoothstep(0.,.08,uv.y)*(1.-smoothstep(.92,1.,uv.x))*(1.-smoothstep(.92,1.,uv.y));
58
+ float depth=max(0.,sea-texture2D(heights,clamp(uv,0.,1.)).r*amplitude*inside);
59
+ vec3 col=mix(vec3(.14,.58,.56),vec3(.025,.18,.27),smoothstep(0.,3.5,depth));
60
+ float wave=sin(world.x*.7+world.z*.55+sin(world.z*.13)*2.-time*1.1)+sin(world.z*1.3-world.x*.4+sin(world.x*.17)*3.+time*.8);
61
+ float foam=(1.-smoothstep(.12,1.1,depth))*(.55+.45*sin(depth*15.-time*2.+wave*.5));
62
+ col=mix(col,vec3(.84,.93,.83),foam*.8);col+=pow(max(0.,wave*.5),12.)*.035*(1.-lite*.7);
63
+ float haze=1.-exp(-distance(cameraPosition,world)*.0009);col=mix(col,vec3(.35,.52,.60),haze);
64
+ gl_FragColor=vec4(col,1.); #include <tonemapping_fragment>
65
+ #include <colorspace_fragment>
66
+ }`.replace(' #include','\n#include')});
67
+ const water=new THREE.Mesh(new THREE.PlaneGeometry(width*12,depth*12),waterMaterial); water.rotation.x=-Math.PI/2; water.position.set((x0+x1)/2,data.seaLevel,(z0+z1)/2); scene.add(water);
68
+ // Densify straight segments before draping: avoids spline overshoot and tubes cutting hills.
69
+ const points=[];
70
+ for(let i=1;i<data.route.coordinates.length;i++) {
71
+ const a=data.route.coordinates[i-1],b=data.route.coordinates[i];
72
+ const steps=Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(span/segments/2)));
73
+ for(let j=0;j<steps;j++){const t=j/steps,x=a[0]+(b[0]-a[0])*t,z=a[1]+(b[1]-a[1])*t;points.push(new THREE.Vector3(x,Math.max(sample(x,z),data.seaLevel)+span*.008,z));}
74
+ }
75
+ const last=data.route.coordinates.at(-1); points.push(new THREE.Vector3(last[0],Math.max(sample(...last),data.seaLevel)+span*.008,last[1]));
76
+ const path=new THREE.CurvePath(); for(let i=1;i<points.length;i++)path.add(new THREE.LineCurve3(points[i-1],points[i]));
77
+ scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.003,5,false),new THREE.MeshBasicMaterial({color:'#ffcd61'})));
78
+ scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.006,5,false),new THREE.MeshBasicMaterial({color:'#ffcc65',transparent:true,opacity:.12,depthWrite:false})));
79
+ const flowGeometry=new THREE.SphereGeometry(span*.004,6,4);
80
+ const flow=new THREE.InstancedMesh(flowGeometry,new THREE.MeshBasicMaterial({color:'#ffffff'}),12);scene.add(flow);
81
+ const flowMatrix=new THREE.Matrix4();
82
+ const labels=document.createElement('div');labels.className='terrain-labels';root.append(labels);
83
+ const labelPoints=data.markers.map((marker,index)=>{
84
+ const [x,z]=marker.coordinates,y=Math.max(sample(x,z),data.seaLevel);
85
+ const mesh=new THREE.Mesh(new THREE.CylinderGeometry(span*.003,span*.003,span*.035,8),new THREE.MeshBasicMaterial({color:'#fff2c4'}));mesh.position.set(x,y+span*.0175,z);scene.add(mesh);
86
+ const line=document.createElement('i');line.setAttribute('aria-hidden','true');labels.append(line);
87
+ const label=document.createElement('span');label.textContent=`${String(index+1).padStart(2,'0')} ${marker.label}`;labels.append(label);
88
+ return {label,line,position:new THREE.Vector3(x,y+span*.04,z)};
89
+ });
90
+ const accessible=document.createElement('ul');accessible.className='terrain-accessible';
91
+ for(const m of data.markers){const li=document.createElement('li');li.textContent=m.label;accessible.append(li);}root.append(accessible);
92
+ labels.setAttribute('aria-hidden','true');
93
+ let scheduled=0,disposed=false,visible=true,previous=0,start=0;
94
+ const projected=new THREE.Vector3();
95
+ function render(now){scheduled=0;if(disposed||!visible||document.hidden){previous=0;return;}
96
+ if(!start)start=now; const elapsed=(now-start)/1000;
97
+ if(quality==='auto'&&!lite&&previous&&elapsed>2&&monitor(now-previous)){useLite();waterMaterial.uniforms.lite.value=1;}
98
+ previous=now;waterMaterial.uniforms.time.value=reduced?0:elapsed;
99
+ if(intro){const t=Math.min(1,elapsed/2.2);reset();const offset=camera.position.clone().sub(controls.target);offset.applyAxisAngle(new THREE.Vector3(0,1,0),.15*(1-t)*(1-t));camera.position.copy(controls.target).add(offset);controls.update();if(t===1)intro=false;}
100
+ for(let i=0;i<12;i++){const p=path.getPoint(((reduced?0:elapsed*.035)+i/12)%1);flowMatrix.makeTranslation(p.x,p.y,p.z);flow.setMatrixAt(i,flowMatrix);}flow.instanceMatrix.needsUpdate=true;
101
+ renderer.render(scene,camera);
102
+ const projectedLabels=labelPoints.map(({label,position})=>{projected.copy(position).project(camera);return {x:(projected.x*.5+.5)*canvas.clientWidth,y:(-projected.y*.5+.5)*canvas.clientHeight,w:label.offsetWidth,h:label.offsetHeight,visible:projected.z<=1&&projected.z>=-1};});
103
+ const boxes=placeLabels(projectedLabels,canvas.clientWidth,canvas.clientHeight);
104
+ labelPoints.forEach(({label,line},i)=>{const box=boxes[i],p=projectedLabels[i];label.style.visibility=line.style.visibility=box&&p.visible?'visible':'hidden';if(!box)return;
105
+ label.style.transform=`translate(${box.x}px,${box.y}px)`;
106
+ const dx=box.x+box.w/2-p.x,dy=box.y+box.h-p.y;line.style.width=`${Math.hypot(dx,dy)}px`;line.style.transform=`translate(${p.x}px,${p.y}px) rotate(${Math.atan2(dy,dx)}rad)`;
107
+ });
108
+ if(!reduced)invalidate();
109
+ }
110
+ function invalidate(){if(!scheduled&&!disposed)scheduled=requestAnimationFrame(render);}
111
+ controls.addEventListener('start',()=>{intro=false;});
112
+ controls.addEventListener('change',invalidate);
113
+ let initialSize=true;
114
+ const resize=new ResizeObserver(()=>{const w=root.clientWidth,h=root.clientHeight;if(!w||!h)return;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();if(initialSize){initialSize=false;reset();}invalidate();});resize.observe(root);
115
+ const intersection=new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;if(visible)invalidate();});intersection.observe(root);
116
+ document.addEventListener('visibilitychange',invalidate);
117
+ function command(action){
118
+ intro=false;
119
+ const offset=camera.position.clone().sub(controls.target),s=new THREE.Spherical().setFromVector3(offset);
120
+ if(action==='left')s.theta-=.15; if(action==='right')s.theta+=.15;
121
+ if(action==='up')s.phi=Math.max(.15,s.phi-.1);if(action==='down')s.phi=Math.min(controls.maxPolarAngle,s.phi+.1);
122
+ if(action==='in')s.radius=Math.max(controls.minDistance,s.radius*.85);if(action==='out')s.radius=Math.min(controls.maxDistance,s.radius/ .85);
123
+ if(action==='reset')reset();else {camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(s));controls.update();}invalidate();
124
+ }
125
+ const keyActions={ArrowLeft:'left',ArrowRight:'right',ArrowUp:'up',ArrowDown:'down','+':'in','=':'in','-':'out',Home:'reset'};
126
+ function keydown(e){if(keyActions[e.key]){e.preventDefault();command(keyActions[e.key]);}}canvas.addEventListener('keydown',keydown);
127
+ const toolbar=document.createElement('div');toolbar.className='terrain-controls';toolbar.setAttribute('aria-label','Map camera');
128
+ for(const [action,label] of [['left','Rotate left'],['right','Rotate right'],['in','Zoom in'],['out','Zoom out'],['reset','Reset view']]){const button=document.createElement('button');button.type='button';button.textContent=label;button.onclick=()=>command(action);toolbar.append(button);}root.append(toolbar);
129
+ invalidate();
130
+ return {camera,renderer,command,get quality(){return lite?'lite':'full';},render:invalidate,dispose(){disposed=true;cancelAnimationFrame(scheduled);resize.disconnect();intersection.disconnect();document.removeEventListener('visibilitychange',invalidate);canvas.removeEventListener('keydown',keydown);controls.dispose();scene.traverse(o=>{o.geometry?.dispose();if(o.material)o.material.dispose();});heightTexture.dispose();renderer.dispose();canvas.remove();labels.remove();accessible.remove();toolbar.remove();}};
131
+ }
@@ -0,0 +1,3 @@
1
+ .terrain-map{position:relative;min-height:360px;height:100%;overflow:hidden;background:#102d39;border-radius:24px}.terrain-map canvas{width:100%;height:100%;display:block;touch-action:none}.terrain-map canvas:focus-visible{outline:3px solid #ffe9a6;outline-offset:-4px}.terrain-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}.terrain-labels span{position:absolute;top:0;left:0;background:#102329e8;border:1px solid #91b1ac;border-radius:8px;padding:5px 9px;color:#f5f2de;font:600 13px system-ui;white-space:nowrap}.terrain-labels [hidden]{display:none}.terrain-controls{position:absolute;bottom:12px;left:12px;right:12px;display:flex;justify-content:center;flex-wrap:wrap;gap:6px}.terrain-controls button{min-height:44px;padding:10px 12px;border:1px solid #91b1ac;background:#102329ed;color:#fff4cf;border-radius:10px;font:600 13px system-ui;cursor:pointer}.terrain-controls button:focus-visible{outline:3px solid #ffe9a6;outline-offset:2px}
2
+ .terrain-labels span{border:1px solid #e6d7a070;border-radius:5px;padding:9px 12px;background:#142f39ed;box-shadow:0 4px 18px #03192345;letter-spacing:.03em;font-size:12px}.terrain-labels i{position:absolute;left:0;top:0;height:1px;background:#ffe7a9b0;transform-origin:0 0}.terrain-accessible{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(min-width:1400px){.terrain-labels span{font-size:18px;padding:12px 18px}.terrain-controls button{font-size:16px}}
3
+ .terrain-labels span{max-width:min(240px,calc(100% - 40px));white-space:normal;overflow-wrap:anywhere;box-sizing:border-box}
@@ -0,0 +1,5 @@
1
+ {
2
+ "segments": 192,
3
+ "pixelRatio": 1.5,
4
+ "quality": "auto"
5
+ }
package/tools/tools.ts CHANGED
@@ -16,7 +16,7 @@ export interface ToolContext {
16
16
  };
17
17
  bookings: {
18
18
  availability(args: {app: string; collection: string; after?: string; quantity?: number; option?: string; resource?: string; addons?: Record<string,number>}): Promise<BookingAvailability>;
19
- calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
19
+ calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string;exceptions?:Record<string,null|{time?:string;capacity?:number;durationMinutes?:number}>}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
20
20
  hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
21
21
  get(args: {id: string; token?: string}): Promise<BookingHold>;
22
22
  confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
@@ -11,15 +11,28 @@ function instant(day,time,format) {
11
11
  return matches[0];
12
12
  }
13
13
  /** Inclusive season dates; each returned slot may span several local calendar days. */
14
- export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
14
+ export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot',exceptions={}}) {
15
15
  const start=date(startDate),end=date(endDate);
16
+ if(!exceptions||Array.isArray(exceptions)||typeof exceptions!=='object')throw new Error('Invalid calendar exceptions');
17
+ for(const [day,change] of Object.entries(exceptions)){
18
+ const at=date(day);
19
+ if(at<start||at>end||weekdays&&!weekdays.includes(new Date(at).getUTCDay()))throw new Error('Exception is outside the series');
20
+ if(change!==null&&(!change||typeof change!=='object'||Array.isArray(change)||Object.keys(change).some(k=>!['time','capacity','durationMinutes'].includes(k)||change[k]===undefined)))throw new Error('Invalid calendar exception');
21
+ }
16
22
  if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
17
23
  if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
18
24
  if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
19
25
  const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
20
26
  for(let at=start;at<=end;at+=dayMs) {
21
27
  if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
22
- const day=new Date(at).toISOString().slice(0,10),from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
28
+ const day=new Date(at).toISOString().slice(0,10);
29
+ if(Object.hasOwn(exceptions,day)){
30
+ const change=exceptions[day];if(change===null)continue;
31
+ const [slot]=calendarSlots({startDate:day,endDate:day,timeZone,time,days:Object.hasOwn(change,'durationMinutes')?undefined:days,durationMinutes,capacity,resources,options,prefix,...change});
32
+ // Identity follows the series occurrence, even when its wall time changes.
33
+ slots.push({...slot,id:`${prefix}-${day}-${time.replace(':','')}`});continue;
34
+ }
35
+ const from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
23
36
  slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
24
37
  }
25
38
  return slots;
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.57.0","sha256":"0a84d357448b7257ee97cac637957a6fe5bb46323de76625644d88e80a684a87"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.0","sha256":"c8953383f91877ce12a615ce9c730f7d5fd4859acf285efe5132404ef70c8895"});
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.57.0';
2
+ export const STUDIO_VERSION_TAG = '0.58.0';