diff --git a/src/vcen-edit/comps/SecPanel.svelte b/src/vcen-edit/comps/SecPanel.svelte
index 90ecf32..6e0a620 100644
--- a/src/vcen-edit/comps/SecPanel.svelte
+++ b/src/vcen-edit/comps/SecPanel.svelte
@@ -9,6 +9,7 @@ import RuleList from './RuleList.svelte'
import IssueSection from './IssueSection.svelte'
import SecAttsPanel from './SecAttsPanel.svelte'
import MetaPanel from './MetaPanel.svelte'
+import VisualPanel from './VisualPanel.svelte'
const editor = getContext
('catalog-editor')
const glob = getContext('editor-glob')
@@ -23,7 +24,10 @@ const ctx = $derived(
: null)
const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === (ctx?.secId as string | undefined))
-const tab = $derived(sel.kind === 'sec-atts' ? 'atts' : sel.kind === 'sec-meta' ? 'meta' : sel.kind === 'sec-issues' ? 'issues' : 'info')
+const tabFromSel = $derived(sel.kind === 'sec-atts' ? 'atts' : sel.kind === 'sec-meta' ? 'meta' : sel.kind === 'sec-issues' ? 'issues' : 'info')
+let extraTab = $state<'visual' | null>(null)
+$effect(() => { void sel; extraTab = null })
+const tab = $derived(extraTab ?? tabFromSel)
let editId = $state('')
let idError = $state('')
@@ -50,7 +54,9 @@ function cancelNew() {
editor.select({ kind: 'obj-info', objId: ctx.objId })
}
-function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
+function goTab(t: 'info' | 'atts' | 'issues' | 'meta' | 'visual') {
+ if (t === 'visual') { extraTab = 'visual'; return }
+ extraTab = null
if (!ctx) return
const { objId, secId } = ctx
if (t === 'info') editor.select({ kind: 'sec-info', objId, secId })
@@ -93,13 +99,14 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{#each [
{ id: 'info', label: 'General' },
{ id: 'atts', label: 'Atributos' },
+ { id: 'visual', label: 'Visual' },
{ id: 'issues', label: 'Reglas/Incidencias' },
{ id: 'meta', label: 'Metadatos' },
] as t (t.id)}
{@const isActive = tab === t.id}
@@ -229,30 +236,22 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{/if}
-
-
-
diff --git a/src/vcen-edit/comps/VisualPanel.svelte b/src/vcen-edit/comps/VisualPanel.svelte
new file mode 100644
index 0000000..0c1a559
--- /dev/null
+++ b/src/vcen-edit/comps/VisualPanel.svelte
@@ -0,0 +1,879 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Vistas
+
+
+
+ {#if views.length === 0}
+
Sin vistas
+ {:else}
+ {#each views as view, idx (view.id)}
+ {@const isViewSel = selected !== 'general' && (selected as any).viewId === view.id}
+ {@const isViewActive = isViewSel && (selected as any).kind === 'view'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each (view.hotspots ?? []) as hs (hs.id)}
+ {@const isHsSel = selected !== 'general'
+ && (selected as any).kind === 'hotspot'
+ && (selected as any).viewId === view.id
+ && (selected as any).hotspotId === hs.id}
+ {@const hsLabel = getLabelStr(hs.label)}
+
+
+
+ {/each}
+ {/each}
+ {/if}
+
+
+
+
+
+ {#if selected === 'general'}
+
+
Configuración general
+
+
+
+ patchVisual({ basePath: e.currentTarget.value || undefined })}
+ placeholder={catDefaults.basePath || 'Heredado del catálogo'}
+ class="field-input text-xs font-mono" />
+
+
+
+
+ patchVisual({ fallback: e.currentTarget.value || undefined })}
+ placeholder={catDefaults.fallback || 'Heredado del catálogo'}
+ class="field-input text-xs font-mono" />
+
+
+ {#if views.length > 0}
+
+
+
+
+ {:else}
+
Añade vistas desde el panel lateral.
+ {/if}
+
+
+ {:else if selectedView && (selected as any).kind === 'view'}
+ {@const view = selectedView}
+ {@const isNew = newViewId === view.id}
+
+
+
+
+
{isNew ? 'Nueva vista' : (getLabelStr(view.label) || view.code)}
+
+ {#if isNew}
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {#if isNew}
+
+ vw_
+ viewIdError = ''}
+ onkeydown={e => { if (e.key === 'Enter') saveViewNew() }}
+ placeholder="frontal, detalle-rueda…"
+ class={['field-input font-mono text-xs flex-1', viewIdError ? 'border-red-600' : ''].join(' ')} />
+
+ {#if viewIdError}
{viewIdError}{/if}
+ {:else}
+
{view.id}
+ {/if}
+
+
+
+
+
+
+ {
+ updateView(view.id, { code: e.currentTarget.value })
+ validateViewField('code', e.currentTarget.value)
+ }}
+ placeholder="front, detail-wheel…"
+ class={['field-input font-mono text-xs', codeError ? 'border-red-600' : ''].join(' ')} />
+ {#if codeError}{codeError}{/if}
+
+
+
+
+
+
+
+
+
+
+
+
updateView(view.id, { label: v as any })} />
+
+
+
+
+ updateView(view.id, { icon: e.currentTarget.value || undefined })}
+ placeholder="car-front, image, eye…"
+ class="field-input text-xs font-mono" />
+
+
+
+
+
+
+
+
+
+ {#if view.config.strategy === 'static'}
+
+
+ updateView(view.id, { config: { ...view.config, url: e.currentTarget.value } as any })}
+ placeholder="images/front.jpg"
+ class="field-input font-mono text-xs" />
+
+ {:else if view.config.strategy === 'dynamic'}
+
+
+ updateView(view.id, { config: { ...view.config, template: e.currentTarget.value } as any })}
+ placeholder={"car_{at_color}.jpg"}
+ class="field-input font-mono text-xs" />
+
+ {:else if view.config.strategy === 'api'}
+
+
+ updateView(view.id, { config: { ...view.config, endpoint: e.currentTarget.value } as any })}
+ placeholder="https://render.example.com"
+ class="field-input font-mono text-xs" />
+
+ {:else if view.config.strategy === 'd3'}
+
+
+ updateView(view.id, { config: { ...view.config, modelUrl: e.currentTarget.value } as any })}
+ placeholder="models/object.glb"
+ class="field-input font-mono text-xs" />
+
+ {:else}
+
+
+ Editable en modo avanzado
+
+ {/if}
+
+
+
+
+
+
+ updateView(view.id, {
+ metadata: Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m
+ })} />
+
+
+
+
+
+ {:else if selectedView && selectedHotspot && (selected as any).kind === 'hotspot'}
+ {@const view = selectedView}
+ {@const hs = selectedHotspot}
+ {@const isNew = newHotspotId === hs.id}
+
+
+
+
+
{isNew ? 'Nuevo hotspot' : (getLabelStr(hs.label) || hs.id)}
+
+ {#if isNew}
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {#if isNew}
+
+ hs_
+ hotspotIdError = ''}
+ onkeydown={e => { if (e.key === 'Enter') saveHotspotNew() }}
+ placeholder="rueda, interior…"
+ class={['field-input font-mono text-xs flex-1', hotspotIdError ? 'border-red-600' : ''].join(' ')} />
+
+ {#if hotspotIdError}
{hotspotIdError}{/if}
+ {:else}
+
{hs.id}
+ {/if}
+
+
+
+
updateHotspot(view.id, hs.id, { label: v as any })} />
+
+
+
+
+
+
+
+
+
+ updateHotspot(view.id, hs.id, { targetAtt: (e.currentTarget.value || undefined) as any })}
+ placeholder="at_color…"
+ class="field-input font-mono text-xs" />
+
+
+
+
+
+
+
+ {
+ const v = clampPos(parseFloat(e.currentTarget.value) || 0)
+ updateHotspot(view.id, hs.id, { posX: v })
+ validateHotspotField('posX', v)
+ }}
+ placeholder="50"
+ class={['field-input font-mono text-xs', posXError ? 'border-red-600' : ''].join(' ')} />
+ {#if posXError}{posXError}{/if}
+
+
+
+ {
+ const v = clampPos(parseFloat(e.currentTarget.value) || 0)
+ updateHotspot(view.id, hs.id, { posY: v })
+ validateHotspotField('posY', v)
+ }}
+ placeholder="50"
+ class={['field-input font-mono text-xs', posYError ? 'border-red-600' : ''].join(' ')} />
+ {#if posYError}{posYError}{/if}
+
+
+
+
+
+
+
+
+
+ updateHotspot(view.id, hs.id, { radius: parseInt(e.currentTarget.value) || undefined })}
+ placeholder="16"
+ class="field-input text-xs" />
+
+
+
+ updateHotspot(view.id, hs.id, { zIndex: parseInt(e.currentTarget.value) || undefined })}
+ placeholder="10"
+ class="field-input text-xs" />
+
+
+
+
+ updateHotspot(view.id, hs.id, { tooltip: (Object.keys(v).length ? v : undefined) as any })} />
+
+
+
+
+
+ updateHotspot(view.id, hs.id, {
+ metadata: Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m
+ })} />
+
+
+
+
+ {/if}
+
+
+
+
+ {#if showPreview && previewView}
+
+
+ Preview
+
+
+
+
+
+
+ {#if previewUrl}
+

+ {:else}
+
+
+
{pickingPos ? 'Haz click para posicionar' : 'Sin imagen'}
+
+ {/if}
+
+
+ {#each (previewView.hotspots ?? []) as marker (marker.id)}
+ {@const isActive = selectedHotspot?.id === marker.id}
+ {@const mX = typeof marker.posX === 'number' ? marker.posX : parseFloat(String(marker.posX)) || 0}
+ {@const mY = typeof marker.posY === 'number' ? marker.posY : parseFloat(String(marker.posY)) || 0}
+ {@const size = (marker.radius ?? 8) * 2}
+
+ {/each}
+
+
+
+
+ {previewView.width || '?'} x {previewView.height || '?'} px
+ {#if pickingPos}
+ Click en imagen para posicionar
+ {/if}
+
+
+
+ {/if}
+
+
diff --git a/src/vcen-edit/stores/catalog-editor.svelte.ts b/src/vcen-edit/stores/catalog-editor.svelte.ts
index e980771..d2fcf8d 100644
--- a/src/vcen-edit/stores/catalog-editor.svelte.ts
+++ b/src/vcen-edit/stores/catalog-editor.svelte.ts
@@ -348,6 +348,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function setObjVisual(objId: ObjID, visual: Visual | undefined) {
+ mutate(() => {
+ const obj = findObj(objId);
+ if (obj) obj.visual = visual;
+ });
+ }
+
function setObjIssues(objId: ObjID, issues: Issue[]) {
mutate(() => {
const obj = findObj(objId);
@@ -458,6 +465,13 @@ export function createCatalogEditorStore(initial: Cat) {
});
}
+ function setSecVisual(objId: ObjID, secId: SecID, visual: Visual | undefined) {
+ mutate(() => {
+ const sec = findSec(objId, secId);
+ if (sec) sec.visual = visual;
+ });
+ }
+
function setSecIssues(objId: ObjID, secId: SecID, issues: Issue[]) {
mutate(() => {
const sec = findSec(objId, secId);
@@ -501,6 +515,7 @@ export function createCatalogEditorStore(initial: Cat) {
} else {
select({ kind: 'obj-atts', objId, attId: att.id });
}
+ newEntityId = att.id;
return att.id;
}
@@ -509,6 +524,7 @@ export function createCatalogEditorStore(initial: Cat) {
mutate(() => { cat.atts.push(att); });
logr.info('catalog-editor', { es: 'Atributo de catálogo creado' }, { id: att.id });
select({ kind: 'cat-atts', attId: att.id });
+ newEntityId = att.id;
return att.id;
}
@@ -731,6 +747,7 @@ export function createCatalogEditorStore(initial: Cat) {
if ('attId' in s && s.attId === attId) {
selection = { ...s, attId: newId } as Selection;
}
+ if (newEntityId === (attId as string)) newEntityId = newId as string;
}
// ── Opts globales ───────────────────────────────────────────────────────
@@ -977,12 +994,12 @@ export function createCatalogEditorStore(initial: Cat) {
// Obj
addObj, cloneObj, renameObj, setObjLabel, removeObj, moveObj,
setObjRuleSet, updateObjMeta,
- updateObjInfo, updateObjPricing, updateObjVisual, setObjIssues,
+ updateObjInfo, updateObjPricing, updateObjVisual, setObjVisual, setObjIssues,
// Sec
addSec, cloneSec, renameSec, setSecLabel, removeSec, moveSec,
setSecRuleSet, updateSecMeta,
- updateSecInfo, updateSecPricing, updateSecVisual, setSecIssues,
+ updateSecInfo, updateSecPricing, updateSecVisual, setSecVisual, setSecIssues,
// Att (obj/sec level)
addAtt, addCatAtt, renameAtt, setAttLabel, removeAtt, moveAtt,
diff --git a/src/vcen/lib/types/att.ts b/src/vcen/lib/types/att.ts
index 8cf05dd..34b200c 100644
--- a/src/vcen/lib/types/att.ts
+++ b/src/vcen/lib/types/att.ts
@@ -89,7 +89,7 @@ export interface AttOpt {
optId: OptID;
/** Override de etiqueta para este att concreto */
- label?: LingString;
+ label?: LingRecord;
/** override el code para este att concreto */
code?: string;
diff --git a/src/vcen/lib/types/view.ts b/src/vcen/lib/types/view.ts
index 8ca21f2..c47cb27 100644
--- a/src/vcen/lib/types/view.ts
+++ b/src/vcen/lib/types/view.ts
@@ -334,6 +334,10 @@ export interface View {
code: string;
viewType: ViewType;
config: ViewConfig;
+
+ width : number;
+ height: number;
+
/** Orden de presentación en la UI. Menor = primero */
order?: number;
icon?: string;