/* Standalone design prototype. All data stays in this origin's localStorage. The field map for the product (SpaceDialogState, i18n keys, controls) is docs/FIELD-MAP.md in the handoff archive. */ import {registerDemoForm, openDemoForm, notifyDemoFormClosed} from './demo-shell.js'; import {DEFAULT_CUSTOM_FILL} from './room-fill.js'; import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; const STORAGE_KEY = 'houseplan-space-settings-design-v1'; const base = { title:'Ground Floor', source:'draw', planName:'', planData:'', cellCm:5, showBorders:true, zeroWallStyle:'dashed', showNames:true, showLqi:false, hideDecor:false, hideOpenings:false, labelTemp:true, labelHum:true, labelLqi:true, labelLight:true, cardFontScale:100, roomColor:'#52636b', roomOpacity:85, fillMode:'custom', fillColor:DEFAULT_CUSTOM_FILL.c, fillOpacity:DEFAULT_CUSTOM_FILL.a*100, customFillSet:false, tempMin:20, tempMax:25, bgMode:'daynight', bgColor:'#edf1f2', bgColorSet:false, northDeg:null, sunRays:'inherit', glowEnabled:true, }; const initialSpaces = [ // Keep the approved visual example reproducible in a fresh browser. {id:'ground-floor', devices:50, settings:{...clone(base),zeroWallStyle:'solid',showLqi:true,roomColor:'#3b4245',fillColor:'#ededed',fillOpacity:100,customFillSet:true}}, {id:'first-floor', devices:0, settings:{...clone(base),title:'First Floor',source:'file',planName:'first-floor.svg',planData:'assets/sample-plan.svg'}}, ]; function loadSpaces() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!Array.isArray(data) || !data.length) return clone(initialSpaces); const valid = data.every(s => typeof s.id === 'string' && Number.isInteger(s.devices) && s.settings && Object.entries(base).every(([k,v]) => k === 'northDeg' ? s.settings[k] === null || Number.isFinite(s.settings[k]) : typeof s.settings[k] === typeof v)); return valid ? data : clone(initialSpaces); } catch { return clone(initialSpaces); } } let spaces = loadSpaces(); let activeId = spaces[0].id; let saved = clone(spaces[0].settings); let draft = clone(saved); const customFill = () => draft.customFillSet?{c:draft.fillColor,a:Number(draft.fillOpacity)/100}:DEFAULT_CUSTOM_FILL; const currentScroller = () => $('#dialog-scroll'); let lastSubTrigger; let closeDecision; const dialog = $('#settings'); const content = $('#settings-content'); const subDialog = $('#sub-dialog'); const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); const activeSpace = () => spaces.find(s => s.id === activeId); function errorFor(key) { if(key === 'title' && !draft.title.trim()) return 'Enter a name for this space.'; if(key === 'cellCm' && (!Number.isFinite(Number(draft.cellCm)) || draft.cellCm === '' || Number(draft.cellCm)<.1 || Number(draft.cellCm)>1000)) return 'Enter a grid size between 0.1 and 1000 cm.'; if(key === 'source' && draft.source === 'file' && !draft.planData) return 'Choose a floor-plan image to continue.'; if(key === 'tempMax' && draft.fillMode === 'temp' && (draft.tempMin === '' || draft.tempMax === '' || !Number.isFinite(Number(draft.tempMin)) || !Number.isFinite(Number(draft.tempMax)) || Number(draft.tempMin)>=Number(draft.tempMax))) return 'The maximum must be higher than the minimum.'; if(key === 'northDeg' && draft.northDeg !== null && (draft.northDeg === '' || !Number.isInteger(Number(draft.northDeg)) || Number(draft.northDeg)<0 || Number(draft.northDeg)>359)) return 'Enter a whole number between 0° and 359°.'; if(key === 'cardFontScale' && (draft.cardFontScale === '' || draft.cardFontScale<50 || draft.cardFontScale>300)) return 'Choose a size between 50% and 300%.'; if((key==='roomOpacity' || (key==='fillOpacity' && draft.fillMode==='custom')) && (draft[key] === '' || draft[key]<0 || draft[key]>100)) return 'Enter an opacity between 0% and 100%.'; return ''; } const errorFields = ['title','cellCm','source','tempMax','northDeg','cardFontScale','roomOpacity','fillOpacity']; const allErrors = () => errorFields.map(key => [key,errorFor(key)]).filter(([,message]) => message); const errorSlot = key => `
${options.hint}
` : ''}${errorSlot(key)}${text}
`; const info = (text, warning=false) => `${text}
Drop your floor plan here
SVG, PNG, JPG or WebP · up to 2 MB in this preview`}Scale of the plan.
Your changes to ${escape(saved.title)} haven’t been saved.
`,``); $('[data-sub="close"].primary',subDialog).focus(); return new Promise(resolve=>{closeDecision=resolve;}); } function persist(nextSpaces) { try {localStorage.setItem(STORAGE_KEY,JSON.stringify(nextSpaces));return true;} catch {showToast('Could not save locally. Try a smaller image or enable browser storage.');return false;} } function saveSettings() { if(!dirty()||allErrors().length)return; const normalized={...clone(draft),title:draft.title.trim()}; const next=spaces.map(s=>s.id===activeId?{...s,settings:normalized}:s); if(!persist(next))return; spaces=next;saved=clone(normalized);draft=clone(saved);closeSettings(); showToast('Space settings saved in this browser.'); } function openGallery() { showSub('Uploaded floor plans','Choose an image for this space.
',''); } function readFile(file) { if(!file)return; if(!/\.(svg|png|jpe?g|webp)$/i.test(file.name)||!['image/svg+xml','image/png','image/jpeg','image/webp'].includes(file.type)){showToast('Choose an SVG, PNG, JPG or WebP image.');return;} if(file.size>2*1024*1024){showToast('Choose an image smaller than 2 MB for this preview.');return;} const reader=new FileReader(); reader.onload=()=>{ const image=new Image(); image.onload=()=>{draft.planData=String(reader.result);draft.planName=file.name;renderMain();updateChrome();}; image.onerror=()=>showToast('This image could not be opened. Choose another file.'); image.src=String(reader.result); }; reader.onerror=()=>showToast('Could not read the image. Please try again.');reader.readAsDataURL(file); } function openCopy() { let n=2;while(spaces.some(s=>s.settings.title===`${saved.title} (${n})`))n++; showSub('Copy space',`Start a new space with the saved layout and display settings. Rooms and device placements are not copied.
This preview creates a local settings copy. The production action also copies the plan geometry.
${dirty()?info('The copy will use saved settings. Your unsaved changes stay in the original draft.'):''}`,``); $('#copy-name').focus();$('#copy-name').select(); } function openDelete() { if(activeSpace().devices>0&&spaces.length>1){ showSub('This space still has devices',`${activeSpace().devices} devices are placed in ${escape(saved.title)}. Move or remove them before deleting this space.
`,``);return; } showSub('Delete this space?',`Delete ${escape(saved.title)} from this design preview? This removes its locally saved settings.
`,``); $('[data-sub="close"].button',subDialog).focus(); } function handleAction(action) { if(action==='review-errors'){ const first=allErrors()[0];if(!first)return; const field=$(`[data-field="${first[0]}"]`,content);field?.scrollIntoView({block:'center'});field?.focus({preventScroll:true});return; } if(action==='upload'){$('#plan-upload').click();return;} if(action==='gallery'){openGallery();return;} if(action==='reset-fill'){draft.fillColor=base.fillColor;draft.fillOpacity=base.fillOpacity;draft.customFillSet=false;} if(action==='reset-background'){draft.bgColor=base.bgColor;draft.bgColorSet=false;} if(action==='reset-room'){draft.roomColor=base.roomColor;draft.roomOpacity=base.roomOpacity;} if(action==='reset-font')draft.cardFontScale=100; if(action==='show-cards')draft.showNames=true; if(action==='inherit-north')draft.northDeg=null; const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll; } document.addEventListener('click',e=>{ const target=e.target.closest('button'); if(target?.dataset.space){activeId=target.dataset.space;renderPicker();} if(target?.dataset.action)handleAction(target.dataset.action); }); $('#settings-content').addEventListener('input',e=>{ if(['checkbox','radio','file'].includes(e.target.type)||e.target.tagName==='SELECT')return; updateField(e.target); }); $('#settings-content').addEventListener('change',e=>{ if(e.target.id==='plan-upload'){readFile(e.target.files[0]);return;} if(e.target.id==='north-mode'){draft.northDeg=e.target.value==='inherit'?null:165;renderMain();updateChrome();return;} if(['checkbox','radio'].includes(e.target.type)||e.target.tagName==='SELECT')updateField(e.target); if(e.target.dataset.field==='cellCm'){const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll;} }); $('#settings-content').addEventListener('dragover',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.add('dragging');}}); $('#settings-content').addEventListener('dragleave',e=>{e.target.closest('#upload-panel')?.classList.remove('dragging');}); $('#settings-content').addEventListener('drop',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.remove('dragging');readFile(e.dataTransfer.files[0]);}}); $('#close-settings').addEventListener('click',requestClose); $('#cancel-settings').addEventListener('click',requestClose); $('#save-settings').addEventListener('click',saveSettings); dialog.addEventListener('cancel',e=>{e.preventDefault();requestClose();}); dialog.addEventListener('keydown',e=>{ if(e.key==='Escape'&&helpOpen()){e.preventDefault();e.stopPropagation();closeHelp();return;} if(e.key==='Escape'&&!subDialog.open){e.preventDefault();e.stopPropagation();requestClose();} }); $('#copy-space').addEventListener('click',openCopy); $('#delete-space').addEventListener('click',openDelete); subDialog.addEventListener('cancel',e=>{e.preventDefault();closeSub();}); subDialog.addEventListener('input',e=>{if(e.target.id==='copy-name')$('[data-sub="confirm-copy"]').disabled=!e.target.value.trim();}); subDialog.addEventListener('click',e=>{ const button=e.target.closest('button[data-sub]');if(!button)return; const action=button.dataset.sub; if(action==='close'){closeSub();return;} if(action==='discard'){ const resolve=closeDecision;closeDecision=undefined; draft=clone(saved);closeSub();closeSettings();resolve?.(true);return; } if(action==='choose-image'){draft.source='file';draft.planName=button.dataset.name;draft.planData='assets/sample-plan.svg';closeSub();renderMain();updateChrome();return;} if(action==='confirm-copy'){ const title=$('#copy-name').value.trim();if(!title)return; const copy={id:crypto.randomUUID(),devices:0,settings:{...clone(saved),title}}; const next=[...spaces,copy];if(!persist(next))return; spaces=next;closeSub();renderPicker();showToast(`“${title}” created. Open it from the space picker.`);return; } if(action==='confirm-delete'){ let next=spaces.filter(s=>s.id!==activeId); if(!next.length)next=[{id:'new-space',devices:0,settings:{...clone(base),title:'New space'}}]; if(!persist(next))return; spaces=next;activeId=next[0].id;closeSub();closeSettings();showToast('Space deleted from this preview.'); } }); window.addEventListener('beforeunload',e=>{if(dialog.open&&dirty()){e.preventDefault();e.returnValue='';}}); hydrateIcons();renderPicker(); registerDemoForm({id:'space-settings',title:'Space settings',description:'Layout, appearance, room cards and lighting for a space.',galleryControls:$('#space-examples'),open:openSettings,requestClose}); openDemoForm('space-settings');