refactor(vcen-edit): modulariza catalog-editor y PanelVisual

- Extrae módulos de stores: editor-initialization, editor-selectors, editor-effects
- Refactoriza PanelVisual en componentes: VisualGeneralPanel, ViewEditor, HotspotEditor, VisualPreviewPanel, VisualSidebar
- Añade validación en condition-editor para LogicNode sin hijos
- Crea librerías auxiliares: constants, guards, validation, clone
- Añade ErrorBoundary component
- Reduce catalog-editor de 1,283 a ~860 líneas (-33%)
- Fix: tests pasan 79/79

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
glm
dev 7 months ago
parent f956144577
commit 030bf3a765

@ -3,7 +3,8 @@ import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
import type { AttDomain, ObjID, SecID, AttID, Obj, Att } from '@/vcen/lib'
import type { LingRecord, LingString } from '@/ling/lib'
import RuleList from './RuleList.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
@ -80,13 +81,88 @@ function defaultDomain(kind: AttDomain['kind']): AttDomain {
}
}
// Helpers para evitar casts en template
function getDomainMode(domain: AttDomain): string {
return 'mode' in domain ? domain.mode : 'input'
}
function asStringDomain(domain: AttDomain) {
if (domain.kind === 'string') return domain
return null
}
function asNumberDomain(domain: AttDomain) {
if (domain.kind === 'number' || domain.kind === 'currency') return domain
return null
}
function asOptionDomain(domain: AttDomain) {
if (domain.kind === 'option') return domain
return null
}
function setAttLabel(label: LingString) {
if (!ctx) return
const { isCatLevel, objId, secId, attId } = ctx
if (isCatLevel) {
editor.setCatAttLabel(attId, label as LingRecord)
} else {
editor.setAttLabel(objId!, secId, attId, label as LingRecord)
}
}
// Helpers para actualizar domain - reciben el kind actual
function updateStringDomain(mode: 'input' | 'list' | 'pattern' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: 'string', mode, ...extra } as AttDomain)
}
function updateNumberDomain(kind: AttDomain['kind'], mode: 'input' | 'range' | 'step' | 'list' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: kind as 'number' | 'currency', mode, ...extra } as AttDomain)
}
function updateOptionDomain(mode: 'list' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: 'option', mode, ...extra } as AttDomain)
}
function updateOptionFixed(value: string) {
updateDomain({ kind: 'option', mode: 'fixed', fixed: (value || undefined) as `op_${string}` | undefined } as AttDomain)
}
// Helpers para acceder a propiedades específicas de domain en template
function getOptionListDomain(domain: AttDomain) {
const d = asOptionDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
function getStringListDomain(domain: AttDomain) {
const d = asStringDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
function getNumberListDomain(domain: AttDomain) {
const d = asNumberDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
// Helpers para evitar casts en template (Svelte no permite 'as' en templates)
function asStringMode(mode: string): 'input' | 'list' | 'pattern' | 'fixed' {
return mode as any
}
function asNumberMode(mode: string): 'input' | 'range' | 'step' | 'list' | 'fixed' {
return mode as any
}
// Opts
const allOpts = $derived(editor.cat.opts)
const attOptIds = $derived.by(() => {
if (!ctx) return new Set<string>()
const d = ctx.att.value.domain
if (d.kind !== 'option' || d.mode !== 'list') return new Set<string>()
return new Set(((d as any).list ?? []).map((ao: any) => ao.optId as string))
const d = asOptionDomain(ctx.att.value.domain)
if (!d || d.mode !== 'list') return new Set<string>()
return new Set((d.list ?? []).map(ao => ao.optId))
})
function toggleOpt(optId: string) {
@ -142,7 +218,11 @@ const computedState = $derived.by(() => {
void editor.rev
if (!ctx?.att.rules) return null
// For cat-level atts, use the cat itself as container proxy
const container = ctx.container ?? (ctx.isCatLevel ? { atts: editor.cat.atts ?? [], id: editor.cat.id } as any : null)
const container = ctx.container ?? (ctx.isCatLevel ? {
atts: editor.cat.atts ?? [],
id: editor.cat.id,
label: {}
} as unknown as Obj : null)
if (!container) return null
const evalCtx = buildEvalCtx(editor.cat, container)
return evalRuleSetState(ctx.att.rules, evalCtx)
@ -208,7 +288,7 @@ function deleteAtt() {
</script>
{#if ctx}
{@const { att, isCatLevel, objId, secId, attId, container } = ctx}
{@const { att, isCatLevel, attId, container } = ctx}
<Panel
title={labelOf(att.label, cat.lang) || glob.t('vcen.editor.common.noName')}
@ -247,10 +327,7 @@ function deleteAtt() {
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Color, Suelo, Material..."
onNameUpdate={label => {
if (isCatLevel) editor.setCatAttLabel(attId, label as any)
else editor.setAttLabel(objId!, secId, attId, label as any)
}}
onNameUpdate={setAttLabel}
/>
</section>
@ -272,13 +349,14 @@ function deleteAtt() {
<!-- Config extra según kind -->
{#if att.value.domain.kind === 'string'}
{@const d = att.value.domain as any}
{@const d = asStringDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.mode')}</label>
<div class="flex gap-2">
{#each ['input', 'list', 'pattern', 'fixed'] as mode}
<button
onclick={() => updateDomain({ kind: 'string', mode: mode as any })}
onclick={() => updateStringDomain(asStringMode(mode))}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -288,38 +366,36 @@ function deleteAtt() {
</div>
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => updateDomain(
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
oninput={e => updateStringDomain('list', {
list: e.currentTarget.value.split('\n').map(s => s.trim()).filter(Boolean)
})}
placeholder={glob.t('vcen.editor.att.listPlaceholder')}
rows={4} class="field-input"
></textarea>
>{(d.list ?? []).join('\n')}</textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'pattern', pattern: e.currentTarget.value })}
oninput={e => updateStringDomain('pattern', { pattern: e.currentTarget.value })}
placeholder="^[A-Z]{2}[0-9]+$" class="field-input" />
{:else if d.mode === 'fixed'}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.fixedValue')}</label>
<input type="text" value={d.fixed ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
oninput={e => updateStringDomain('fixed', { fixed: e.currentTarget.value })}
class="field-input" />
</div>
{/if}
</div>
{/if}
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
{@const d = att.value.domain as any}
{@const d = asNumberDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.mode')}</label>
<div class="flex gap-2 flex-wrap">
{#each ['input', 'range', 'step', 'list', 'fixed'] as mode}
<button
onclick={() => updateDomain(
{ kind: att.value.domain.kind, mode: mode as any } as any)}
onclick={() => updateNumberDomain(att.value.domain.kind, asNumberMode(mode))}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -332,57 +408,56 @@ function deleteAtt() {
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.min')}</span>
<input type="number" value={d.min ?? 0}
oninput={e => updateDomain({ ...d, min: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { min: Number(e.currentTarget.value) })}
class="field-input" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.max')}</span>
<input type="number" value={d.max ?? 100}
oninput={e => updateDomain({ ...d, max: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { max: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{#if d.mode === 'step'}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.step')}</span>
<input type="number" value={d.step ?? 1}
oninput={e => updateDomain({ ...d, step: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { step: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{/if}
</div>
{:else if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => {
const list = e.currentTarget.value
.split('\n')
.map((s: string) => s.trim())
.filter((s: string) => s !== '' && !isNaN(Number(s)))
.map(s => s.trim())
.filter(s => s !== '' && !isNaN(Number(s)))
.map(Number)
updateDomain({ kind: att.value.domain.kind, mode: 'list', list } as any)
updateNumberDomain(att.value.domain.kind, 'list', { list })
}}
placeholder={glob.t('vcen.editor.att.numListPlaceholder')}
rows={4} class="field-input"
></textarea>
>{(d.list ?? []).join('\n')}</textarea>
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => updateDomain(
{ kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)}
oninput={e => updateNumberDomain(att.value.domain.kind, 'fixed', { fixed: Number(e.currentTarget.value) })}
class="field-input" />
{/if}
</div>
{/if}
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
{@const d = asOptionDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<div class="flex gap-2">
{#each [['list', glob.t('vcen.editor.att.modeListLabel')], ['fixed', glob.t('vcen.editor.att.modeFixedLabel')]] as [mode, modeLabel]}
<button
onclick={() => updateDomain(
mode === 'list'
? { kind: 'option', mode: 'list', list: d.list ?? [] }
: { kind: 'option', mode: 'fixed', fixed: d.fixed ?? '' }
)}
onclick={() => mode === 'list'
? updateOptionDomain('list', { list: d.mode === 'list' ? d.list ?? [] : [] })
: updateOptionDomain('fixed', { fixed: d.mode === 'fixed' ? d.fixed ?? '' : '' })
}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -405,7 +480,7 @@ function deleteAtt() {
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900/50">
{#each allOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
{@const attOpt = checked ? (d.list ?? []).find((ao: any) => ao.optId === opt.id) : null}
{@const attOpt = checked && d.mode === 'list' ? (d.list ?? []).find(ao => ao.optId === opt.id) : null}
<div class="px-3 py-2 hover:bg-gray-800 transition-colors">
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" {checked}
@ -420,9 +495,9 @@ function deleteAtt() {
</span>
{/if}
</label>
{#if checked}
{#if checked && attOpt}
<input type="text"
value={typeof attOpt?.label === 'string' ? attOpt.label : (attOpt?.label as any)?.['es'] ?? ''}
value={typeof attOpt.label === 'string' ? attOpt.label : (attOpt.label?.['es'] ?? '')}
oninput={e => updateOptLabel(opt.id, e.currentTarget.value)}
placeholder={glob.t('vcen.editor.att.customLabel')}
class="mt-1.5 ml-7 field-input text-xs" />
@ -439,23 +514,23 @@ function deleteAtt() {
<p class="text-xs text-gray-700 italic">{glob.t('vcen.editor.att.noGlobalOptsSimple')}</p>
{:else}
<select
value={d.fixed ?? ''}
onchange={e => updateDomain({ kind: 'option', mode: 'fixed', fixed: e.currentTarget.value as any })}
onchange={e => updateOptionFixed(e.currentTarget.value)}
class="field-input">
<option value="">{glob.t('vcen.editor.att.selectOption')}</option>
<option value="" selected={!d.fixed}>{glob.t('vcen.editor.att.selectOption')}</option>
{#each allOpts as opt (opt.id)}
<option value={opt.id}>{String(opt.value.value)}</option>
<option value={opt.id} selected={d.fixed === opt.id}>{String(opt.value.value)}</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</section>
<!-- Valor por defecto -->
{#if (att.value.domain as any).mode !== 'fixed'}
{#if getDomainMode(att.value.domain) !== 'fixed'}
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.att.sectionDefault')}</h3>
@ -472,14 +547,15 @@ function deleteAtt() {
{/each}
</div>
{:else if att.value.domain.kind === 'option' && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if att.value.domain.kind === 'option' && getDomainMode(att.value.domain) === 'list'}
{@const list = getOptionListDomain(att.value.domain)}
{#if list}
<select
onchange={e => patch('default', e.currentTarget.value || null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as ao (ao.optId)}
{#each list as ao (ao.optId)}
{@const opt = allOpts.find(o => o.id === ao.optId)}
{#if opt}
<option value={opt.id} selected={att.value.value === opt.id}>
@ -488,32 +564,37 @@ function deleteAtt() {
{/if}
{/each}
</select>
{/if}
{:else if (att.value.domain.kind === 'string') && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if att.value.domain.kind === 'string' && getDomainMode(att.value.domain) === 'list'}
{@const list = getStringListDomain(att.value.domain)}
{@const currentVal = String(att.value.value ?? '')}
{#if list}
<select
value={att.value.value ?? ''}
onchange={e => patch('default', e.currentTarget.value || null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as item, i (i)}
<option value={item}>{item}</option>
<option value="" selected={!currentVal}>{glob.t('vcen.editor.att.noDefault')}</option>
{#each list as item, i (i)}
<option value={item} selected={currentVal === item}>{item}</option>
{/each}
</select>
{/if}
{:else if (att.value.domain.kind === 'number' || att.value.domain.kind === 'currency') && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if (att.value.domain.kind === 'number' || att.value.domain.kind === 'currency') && getDomainMode(att.value.domain) === 'list'}
{@const list = getNumberListDomain(att.value.domain)}
{@const currentVal = att.value.value}
{#if list}
<select
value={att.value.value ?? ''}
onchange={e => patch('default', e.currentTarget.value ? Number(e.currentTarget.value) : null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as item, i (i)}
<option value={item}>{String(item)}</option>
<option value="" selected={currentVal == null}>{glob.t('vcen.editor.att.noDefault')}</option>
{#each list as item, i (i)}
<option value={item} selected={currentVal === item}>{String(item)}</option>
{/each}
</select>
{/if}
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" value={String(att.value.value ?? 0)}

@ -0,0 +1,153 @@
<script lang="ts">
import { logr } from '../lib/logr';
import type { Snippet } from 'svelte';
let { children } = $props<{
children: Snippet
}>();
let error = $state<Error | null>(null);
let errorInfo = $state<unknown | null>(null);
/**
* Manejador de errores global para componentes hijos
* Captura errores que ocurren durante el renderizado
*/
function handleError(err: Error, info: unknown) {
error = err;
errorInfo = info;
logr.error('error-boundary',
{ es: 'Error capturado en componente' },
{
error: err.message,
stack: err.stack,
info: info ? String(info) : undefined
}
);
}
/**
* Reinicia el estado del error para intentar recuperación
*/
function reset() {
error = null;
errorInfo = null;
}
</script>
{#if error}
<div class="error-boundary">
<h2 class="error-boundary__title">Algo salió mal</h2>
<div class="error-boundary__message">
<strong>Error:</strong> {error.message}
</div>
{#if errorInfo}
<div class="error-boundary__info">
<strong>Información adicional:</strong>
<pre class="error-boundary__info-text">{String(errorInfo)}</pre>
</div>
{/if}
<button onclick={reset} class="error-boundary__button">
Intentar de nuevo
</button>
<details class="error-boundary__stack">
<summary class="error-boundary__summary">Stack trace (desarrollo)</summary>
<pre class="error-boundary__stack-text">{error.stack}</pre>
</details>
</div>
{:else}
{@render children()}
{/if}
<style>
.error-boundary {
padding: 24px;
background-color: #fef2f2;
border: 2px solid #ef4444;
border-radius: 12px;
color: #991b1b;
font-family: system-ui, -apple-system, sans-serif;
margin: 16px;
}
.error-boundary__title {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 600;
color: #dc2626;
}
.error-boundary__message {
margin: 0 0 12px 0;
padding: 12px;
background-color: #fee2e2;
border-radius: 6px;
font-size: 14px;
}
.error-boundary__info {
margin: 0 0 16px 0;
}
.error-boundary__info-text {
margin-top: 8px;
padding: 8px;
background-color: #fff;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.error-boundary__button {
padding: 10px 20px;
background-color: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.error-boundary__button:hover {
background-color: #dc2626;
}
.error-boundary__button:active {
background-color: #b91c1c;
}
.error-boundary__stack {
margin-top: 16px;
}
.error-boundary__summary {
cursor: pointer;
font-weight: 500;
color: #991b1b;
padding: 8px 12px;
background-color: #fef2f2;
border-radius: 4px;
margin-bottom: 8px;
}
.error-boundary__summary:hover {
background-color: #fee2e2;
}
.error-boundary__stack-text {
margin: 0;
padding: 12px;
background-color: #fff;
border-radius: 4px;
overflow-x: auto;
font-size: 11px;
line-height: 1.5;
}
</style>

@ -11,6 +11,8 @@ import VisualPreview from './VisualPreview.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import Icon from './Icon.svelte'
import { isValidId } from '@/vcen-edit/lib/validate'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { logr } from '@/vcen-edit/lib/logr.ts'
type LingRec = NonNullable<Hotspot['label']>
@ -114,14 +116,19 @@ const previewResolution = $derived.by((): ViewResolution | null => {
const view = ctx?.view
const visual = ctx?.sec?.visual
if (!view || !previewObj) return null
try { return resolveView(view, previewObj, visual, visual, cat, configState) } catch { return null }
try {
return resolveView(view, previewObj, visual, visual, cat, configState)
} catch (error) {
logr.warn('hotspot-preview',
{ es: 'Error resolviendo vista para hotspot' },
{
hotspotId: ctx?.hotspot?.id,
error: error instanceof Error ? error.message : String(error)
}
);
return null;
}
})
function getLabelStr(label: any): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[cat.lang] ?? Object.values(label)[0] ?? ''
}
</script>
{#if ctx?.hs && ctx.view}

@ -1,10 +1,11 @@
<script lang="ts">
import type { LingRecord} from "@/ling/lib";
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import type { LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
@ -47,6 +48,10 @@ $effect(() => {
if (ctx?.sec) { editId = String(ctx.secId).replace(/^[a-z]+_/, ''); idError = '' }
})
// Helper para LingInput - evita cast en template
function orLing(v: LingRecord | undefined): LingRecord {
return v ?? ({} as LingRecord)
}
function saveNew() {
if (!ctx?.sec) return
@ -132,30 +137,30 @@ function cancelNew() {
name={sec.label}
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Suelo, Paredes..."
onNameUpdate={label => editor.setSecLabel(objId, secId, label as LingRecord)}
namePlaceholder="Ej: Suelo, Paredes..."
onNameUpdate={label => editor.setSecLabel(objId, secId, label)}
/>
</section>
<!-- Info -->
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.entity.info')}</h3>
<LingInput value={(sec.title ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
<LingInput value={orLing(sec.title)} locales={cat.langs} defaultLocale={cat.lang}
as="input" variant="dark" label={glob.t('vcen.editor.entity.title')}
placeholder="Título público de la sección"
onUpdate={v => editor.updateSecInfo(objId, secId, 'title', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Summary ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'title', v || undefined)} />
<LingInput value={orLing(sec.Summary)} locales={cat.langs} defaultLocale={cat.lang}
as="input" variant="dark" label={glob.t('vcen.editor.entity.summary')}
placeholder="Descripción breve"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Summary', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Description ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'Summary', v || undefined)} />
<LingInput value={orLing(sec.Description)} locales={cat.langs} defaultLocale={cat.lang}
as="textarea" variant="dark" label={glob.t('vcen.editor.entity.description')}
placeholder="Descripción detallada"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Description', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Help ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'Description', v || undefined)} />
<LingInput value={orLing(sec.Help)} locales={cat.langs} defaultLocale={cat.lang}
as="textarea" variant="dark" label={glob.t('vcen.editor.entity.help')}
placeholder="Texto de ayuda o instrucciones"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Help', (v as LingRecord) || undefined)} />
onUpdate={v => editor.updateSecInfo(objId, secId, 'Help', v || undefined)} />
</section>
<!-- Precio base -->

@ -19,6 +19,8 @@ import TransactionEditor from './TransactionEditor.svelte'
import type { LingRecord, LingString } from '@/ling/lib'
import { buildScope } from '@/vcen-edit/lib/condition-editor.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { logr } from '@/vcen-edit/lib/logr.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -234,16 +236,21 @@ const previewResolution = $derived.by((): ViewResolution | null => {
const view = ctx?.view
const visual = ctx?.sec?.visual
if (!view || !previewObj) return null
try { return resolveView(view, previewObj, visual, visual, cat, configState) } catch { return null }
try {
return resolveView(view, previewObj, visual, visual, cat, configState)
} catch (error) {
logr.warn('view-preview',
{ es: 'Error resolviendo vista' },
{
viewId: view.id,
error: error instanceof Error ? error.message : String(error)
}
);
return null;
}
})
function updateViewName(v: LingString) { patch({ label: v as LingRecord }) }
function getLabelStr(label: any): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[cat.lang] ?? Object.values(label)[0] ?? ''
}
</script>
{#if ctx?.view}
@ -304,7 +311,7 @@ function getLabelStr(label: any): string {
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.type')}</label>
<select value={view.viewType}
<select bind:value={view.viewType}
onchange={e => patch({ viewType: toViewType(e.currentTarget.value) })}
class="field-input text-xs">
{#each VIEW_TYPE_VALUES as vt (vt)}
@ -344,7 +351,7 @@ function getLabelStr(label: any): string {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.strategy')}</label>
<select value={view.config.strategy}
<select bind:value={view.config.strategy}
onchange={e => patch({ config: defaultConfig(e.currentTarget.value) })}
class="field-input text-xs">
{#each STRATEGY_VALUES as s (s)}
@ -386,11 +393,11 @@ function getLabelStr(label: any): string {
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.method')}</label>
<select value={apiMethod}
<select
onchange={e => patch({ config: { ...apiCfg, method: toApiMethod(e.currentTarget.value) } })}
class="field-input text-xs w-28">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="GET" selected={apiMethod === 'GET'}>GET</option>
<option value="POST" selected={apiMethod === 'POST'}>POST</option>
</select>
</div>
<div class="flex flex-col gap-1">
@ -449,8 +456,8 @@ function getLabelStr(label: any): string {
onkeydown={e => { if (e.key === 'Enter') openLayers = isOpen ? openLayers.filter(x => x !== li) : [...openLayers, li] }}
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-800/40 transition-colors select-none"
>
<svg class="h-2.5 w-2.5 shrink-0 transition-transform text-gray-600 {isOpen ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 shrink-0 fill-current transition-transform text-gray-600 {isOpen ? 'rotate-90' : ''}"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600 shrink-0">
{glob.t('vcen.editor.view.layerN', { n: li + 1 })}
</span>
@ -508,8 +515,8 @@ function getLabelStr(label: any): string {
<div class="border-t border-gray-800/50 pt-2">
<button onclick={() => condOpenLayer === li ? condOpenLayer = null : openLayerCond(li, layer)}
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-gray-600 hover:text-gray-400 transition-colors w-full text-left">
<svg class="h-2.5 w-2.5 transition-transform {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 fill-current transition-transform {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
{glob.t('vcen.editor.view.condition')}
{#if layer.condition}
<span class="ml-auto text-amber-600 normal-case tracking-normal">{glob.t('vcen.editor.view.condActive')}</span>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,232 @@
<script lang="ts">
import { getContext } from 'svelte'
import type {AttID, Hotspot, HotspotMode} from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { LingString, LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import InputIdName from '../InputIdName.svelte'
import ButtonDelete from '../ButtonDelete.svelte'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
// Type conversion helper (cast)
function toOptAttId(s: string): AttID | undefined { return (s || undefined) as AttID | undefined }
let {
viewId,
hotspot,
isNew = false,
editId = '',
idError = '',
locales = ['es'],
defaultLocale,
onUpdate,
onSaveNew,
onCancelNew,
onDelete,
onIdInput,
onNameUpdate,
}: {
viewId: string
hotspot: Hotspot
isNew?: boolean
editId: string
idError: string
locales: string[]
defaultLocale: string
onUpdate: (patch: Partial<Hotspot>) => void
onSaveNew: () => void
onCancelNew: () => void
onDelete: () => void
onIdInput: () => void
onNameUpdate: (label: LingString) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const HOTSPOT_MODES = $derived([
{ value: 'tooltip' as HotspotMode, label: glob.t('vcen.editor.hotspot.modes.tooltip') },
{ value: 'popup' as HotspotMode, label: glob.t('vcen.editor.hotspot.modes.popup') },
])
// Validation state
let posXError = $state('')
let posYError = $state('')
let colorError = $state('')
// Local state for selects (props are readonly in Svelte 5)
let selectedMode = $state<HotspotMode>('tooltip')
// Sync local state when prop changes
$effect(() => {
selectedMode = hotspot.mode
})
function clampPos(v: number): number {
return Math.round(Math.min(100, Math.max(0, v)) * 10) / 10
}
function isValidHexColor(v: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(v)
}
function validateHotspotField(field: 'posX' | 'posY' | 'color', val: unknown) {
if (field === 'posX') posXError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : glob.t('vcen.editor.hotspot.posRange')
if (field === 'posY') posYError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : glob.t('vcen.editor.hotspot.posRange')
if (field === 'color') colorError = (!val || isValidHexColor(String(val))) ? '' : glob.t('vcen.editor.hotspot.colorInvalid')
}
// Alias para LingInput
function orLing(v: LingRecord | undefined): LingRecord {
return v ?? ({} as LingRecord)
}
// Handler para tooltip (puede ser undefined)
function onTooltipUpdate(v: unknown) {
const rec = v as LingRecord
const tooltip = Object.keys(rec).length ? rec : undefined
onUpdate({ tooltip })
}
</script>
<div class="ve-panel-content">
<!-- Header -->
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>{isNew ? glob.t('vcen.editor.hotspot.newTitle') : (getLabelStr(hotspot.label) || hotspot.id)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={onCancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={onSaveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hotspot.label) || hotspot.id}
onconfirm={onDelete}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<InputIdName
prefix="hs"
{isNew}
fullId={hotspot.id}
bind:slug={editId}
idPlaceholder="rueda, interior…"
idError={idError}
onIdInput={onIdInput}
onIdKeydown={e => { if (e.key === 'Enter') onSaveNew() }}
name={hotspot.label ?? {}}
{locales}
{defaultLocale}
onNameUpdate={onNameUpdate}
/>
<!-- Mode + targetAtt -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-mode">{glob.t('vcen.editor.hotspot.mode')}</label>
<select
id="vp-hs-mode"
bind:value={selectedMode}
onchange={() => onUpdate({ mode: selectedMode })}
class="field-input text-xs">
{#each HOTSPOT_MODES as m (m.value)}
<option value={m.value}>{m.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-targetatt">{glob.t('vcen.editor.hotspot.targetAtt')} <span class="text-gray-700">{glob.t('vcen.editor.hotspot.targetAttOptional')}</span></label>
<input id="vp-hs-targetatt" type="text"
value={hotspot.targetAtt ?? ''}
oninput={e => onUpdate({ targetAtt: toOptAttId(e.currentTarget.value) })}
placeholder="at_color…"
class="field-input font-mono text-xs" />
</div>
</div>
<!-- Posición -->
<div class="flex items-end gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label" for="vp-hs-posx">X (%) <span class="text-red-500">*</span></label>
<input id="vp-hs-posx" type="number" min="0" max="100" step="0.1"
value={typeof hotspot.posX === 'number' ? hotspot.posX : parseFloat(String(hotspot.posX)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
onUpdate({ posX: v })
validateHotspotField('posX', v)
}}
placeholder="50"
class={['field-input font-mono text-xs', posXError ? 'border-red-600' : ''].join(' ')} />
{#if posXError}<span class="text-xs text-red-400">{posXError}</span>{/if}
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label" for="vp-hs-posy">Y (%) <span class="text-red-500">*</span></label>
<input id="vp-hs-posy" type="number" min="0" max="100" step="0.1"
value={typeof hotspot.posY === 'number' ? hotspot.posY : parseFloat(String(hotspot.posY)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
onUpdate({ posY: v })
validateHotspotField('posY', v)
}}
placeholder="50"
class={['field-input font-mono text-xs', posYError ? 'border-red-600' : ''].join(' ')} />
{#if posYError}<span class="text-xs text-red-400">{posYError}</span>{/if}
</div>
</div>
<!-- Color + Radio + zIndex -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-color">{glob.t('vcen.editor.hotspot.color')}</label>
<div class="flex gap-1.5 items-center">
<input type="color"
value={hotspot.color ?? '#4f46e5'}
oninput={e => {
onUpdate({ color: e.currentTarget.value })
validateHotspotField('color', e.currentTarget.value)
}}
class="h-8 w-10 rounded border border-gray-800 bg-transparent cursor-pointer p-0.5" />
<input id="vp-hs-color" type="text"
value={hotspot.color ?? ''}
oninput={e => {
onUpdate({ color: e.currentTarget.value || undefined })
validateHotspotField('color', e.currentTarget.value)
}}
placeholder="#4f46e5"
class={['field-input font-mono text-xs flex-1', colorError ? 'border-red-600' : ''].join(' ')} />
</div>
{#if colorError}<span class="text-xs text-red-400">{colorError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-radius">{glob.t('vcen.editor.hotspot.radius')}</label>
<input id="vp-hs-radius" type="number" min="1"
value={hotspot.radius ?? ''}
oninput={e => onUpdate({ radius: parseInt(e.currentTarget.value) || undefined })}
placeholder="16"
class="field-input text-xs" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-zindex">z-Index</label>
<input id="vp-hs-zindex" type="number"
value={hotspot.zIndex ?? ''}
oninput={e => onUpdate({ zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="10"
class="field-input text-xs" />
</div>
</div>
<!-- Tooltip label -->
<LingInput
value={orLing(hotspot.tooltip)}
{locales}
{defaultLocale}
label={glob.t('vcen.editor.hotspot.tooltip')}
as="input"
variant="dark"
onUpdate={onTooltipUpdate} />
</div>

@ -0,0 +1,570 @@
<script lang="ts">
import { getContext } from 'svelte'
import {
type View, type ViewID, type ViewType, type LayerDefinition, type LayersConfig, type ApiConfig,
type AttID, type ImageTemplate, toAttID
} from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { LingString } from '@/ling/lib'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.js'
import InputIdName from '../InputIdName.svelte'
import ButtonDelete from '../ButtonDelete.svelte'
import ConditionBuilder from '../ConditionBuilder.svelte'
import TemplateBuilder from '../TemplateBuilder.svelte'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
type AttrOption = { path: string; label: string; group?: string }
let {
view,
isNew = false,
editId = '',
idError = '',
locales = ['es'],
defaultLocale,
templateAttrs = [],
condStore,
condOpenLayer,
condLayerViewId,
onViewUpdate,
onSaveNew,
onCancelNew,
onDelete,
onIdInput,
onNameUpdate,
}: {
view: View
isNew?: boolean
editId: string
idError: string
locales: string[]
defaultLocale: string
templateAttrs: AttrOption[]
condStore: ReturnType<typeof createConditionBuilderStore>
condOpenLayer: number | null
condLayerViewId: ViewID | null
onViewUpdate: (patch: Partial<View>) => void
onSaveNew: () => void
onCancelNew: () => void
onDelete: () => void
onIdInput: () => void
onNameUpdate: (label: LingString) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const VIEW_TYPES = $derived([
{ value: 'front' as ViewType, label: glob.t('vcen.editor.view.types.front') },
{ value: 'side' as ViewType, label: glob.t('vcen.editor.view.types.side') },
{ value: 'rear' as ViewType, label: glob.t('vcen.editor.view.types.rear') },
{ value: 'top' as ViewType, label: glob.t('vcen.editor.view.types.top') },
{ value: 'isometric' as ViewType, label: glob.t('vcen.editor.view.types.isometric') },
{ value: 'panoramic' as ViewType, label: glob.t('vcen.editor.view.types.panoramic') },
{ value: 'detail' as ViewType, label: glob.t('vcen.editor.view.types.detail') },
{ value: 'blueprint' as ViewType, label: glob.t('vcen.editor.view.types.blueprint') },
{ value: 'custom' as ViewType, label: glob.t('vcen.editor.view.types.custom') },
])
const STRATEGY_LABELS = $derived<Record<string, string>>({
static: glob.t('vcen.editor.view.strategies.static'),
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
layers: glob.t('vcen.editor.view.strategies.layers'),
api: glob.t('vcen.editor.view.strategies.api'),
d3: glob.t('vcen.editor.view.strategies.d3'),
})
const STRATEGY_VALUES = ['static', 'dynamic', 'layers', 'api', 'd3'] as const
// Validation state
let codeError = $state('')
let widthError = $state('')
let heightError = $state('')
// Local state for selects (props are readonly in Svelte 5)
let selectedViewType = $state<ViewType>('front')
let selectedStrategy = $state<View['config']['strategy']>('static')
let selectedApiMethod = $state<'GET' | 'POST'>('GET')
// Sync local state when props change
$effect(() => {
selectedViewType = view.viewType
selectedStrategy = view.config.strategy
if (view.config.strategy === 'api') {
selectedApiMethod = (view.config as unknown as ApiConfig).method ?? 'GET'
}
})
function cfgGet(config: View['config'], key: string): string {
return ((config as unknown) as Record<string, unknown>)[key] as string ?? ''
}
// Default config factory
function defaultConfig(strategy: string): View['config'] {
if (strategy === 'static') return { strategy, url: '' } as View['config']
if (strategy === 'dynamic') return { strategy, template: '' } as View['config']
if (strategy === 'layers') return { strategy, layers: [] } as View['config']
if (strategy === 'api') return { strategy, endpoint: '', method: 'GET', params: [] } as View['config']
if (strategy === 'd3') return { strategy, modelUrl: '', textureAttrs: [] } as View['config']
return { strategy } as View['config']
}
// Handlers para config - usando as unknown as para type-safety
function cfgSet(key: string, val: string) {
onViewUpdate({ config: { ...view.config, [key]: val } as View['config'] })
}
function setViewTemplate(tpl: ImageTemplate) {
onViewUpdate({ config: { ...view.config, template: tpl } as View['config'] })
}
// API helpers
function getApiCfg(): ApiConfig {
return view.config as unknown as ApiConfig
}
function setApiMethod() {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, method: selectedApiMethod } as View['config'] })
}
function setApiEndpointTemplate(tpl: ImageTemplate) {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, endpoint: tpl } as View['config'] })
}
function addApiParam() {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, params: [...(cfg.params ?? []), { key: '', value: '' }] } as View['config'] })
}
function removeApiParam(idx: number) {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, params: (cfg.params ?? []).filter((_, i) => i !== idx) } as View['config'] })
}
function updateApiParam(idx: number, key: string) {
const cfg = getApiCfg()
const params = cfg.params ?? []
onViewUpdate({ config: { ...cfg, params: params.map((p, i) => i === idx ? { ...p, key } : p) } as View['config'] })
}
function setApiParamTemplate(idx: number, tpl: ImageTemplate) {
const cfg = getApiCfg()
const params = cfg.params ?? []
onViewUpdate({ config: { ...cfg, params: params.map((p, i) => i === idx ? { ...p, value: tpl } : p) } as View['config'] })
}
// Layers helpers
function getLayersCfg(): LayersConfig {
return view.config as unknown as LayersConfig
}
function addLayer() {
const cfg = getLayersCfg()
const newLayer: LayerDefinition = { attId: '' as AttID, template: '' }
onViewUpdate({ config: { ...cfg, layers: [...(cfg.layers ?? []), newLayer] } as View['config'] })
}
function removeLayer(idx: number) {
const cfg = getLayersCfg()
onViewUpdate({ config: { ...cfg, layers: (cfg.layers ?? []).filter((_, i) => i !== idx) } as View['config'] })
}
function moveLayer(idx: number, dir: 'up' | 'down') {
const cfg = getLayersCfg()
const arr = [...(cfg.layers ?? [])]
const target = dir === 'up' ? idx - 1 : idx + 1
if (target < 0 || target >= arr.length) return
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
onViewUpdate({ config: { ...cfg, layers: arr } as View['config'] })
}
function updateLayer(idx: number, patch: Partial<LayerDefinition>) {
const cfg = getLayersCfg()
const layers = cfg.layers ?? []
onViewUpdate({ config: { ...cfg, layers: layers.map((l, i) => i === idx ? { ...l, ...patch } : l) } as View['config'] })
}
function setLayerTemplate(idx: number, tpl: ImageTemplate) {
updateLayer(idx, { template: tpl })
}
// Layer condition handlers
function openLayerCond(idx: number, layer: LayerDefinition) {
condLayerViewId = view.id
condOpenLayer = idx
if (layer.condition) condStore.load(layer.condition)
else condStore.reset()
}
function closeLayerCond() { condOpenLayer = null }
function onLayerCondChange() {
if (condOpenLayer === null || !condLayerViewId) return
const compiled = condStore.compiled
const hasCondition = condStore.root.kind === 'logic' ? condStore.root.children.length > 0 : true
updateLayer(condOpenLayer, { condition: (hasCondition && compiled) ? compiled : undefined })
}
</script>
<div class="ve-panel-content">
<!-- Header -->
<div class="flex w-full items-center justify-between section-title" style="margin-bottom:0">
<span class="flex-1 text-left truncate pr-4">{isNew ? glob.t('vcen.editor.view.newTitle') : (getLabelStr(view.label) || view.code)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={onCancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={onSaveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={getLabelStr(view.label) || view.code || view.id}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={onDelete}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<InputIdName
prefix="vw"
{isNew}
fullId={view.id}
bind:slug={editId}
idPlaceholder="frontal, detalle-rueda…"
idError={idError}
onIdInput={onIdInput}
onIdKeydown={e => { if (e.key === 'Enter') onSaveNew() }}
name={view.label ?? {}}
{locales}
{defaultLocale}
onNameUpdate={onNameUpdate}
/>
<!-- Code + Type -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-code">{glob.t('vcen.editor.view.code')} <span class="text-red-500">*</span></label>
<input id="vp-view-code" type="text"
value={view.code}
oninput={e => {
const val = e.currentTarget.value
onViewUpdate({ code: val })
codeError = val.trim() ? '' : glob.t('vcen.editor.view.codeRequired')
}}
placeholder="front, detail-wheel…"
class={['field-input font-mono text-xs', codeError ? 'border-red-600' : ''].join(' ')} />
{#if codeError}<span class="text-xs text-red-400">{codeError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-type">{glob.t('vcen.editor.view.type')}</label>
<select
id="vp-view-type"
bind:value={selectedViewType}
onchange={() => onViewUpdate({ viewType: selectedViewType })}
class="field-input text-xs">
{#each VIEW_TYPES as vt (vt.value)}
<option value={vt.value}>{vt.label}</option>
{/each}
</select>
</div>
</div>
<!-- Width + Height -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-width">{glob.t('vcen.editor.view.width')} <span class="text-red-500">*</span></label>
<input id="vp-view-width" type="number" min="1"
value={view.width ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
onViewUpdate({ width: v })
widthError = (v > 0) ? '' : glob.t('vcen.editor.view.dimPositive')
}}
placeholder="800"
class={['field-input text-xs', widthError ? 'border-red-600' : ''].join(' ')} />
{#if widthError}<span class="text-xs text-red-400">{widthError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-height">{glob.t('vcen.editor.view.height')} <span class="text-red-500">*</span></label>
<input id="vp-view-height" type="number" min="1"
value={view.height ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
onViewUpdate({ height: v })
heightError = (v > 0) ? '' : glob.t('vcen.editor.view.dimPositive')
}}
placeholder="600"
class={['field-input text-xs', heightError ? 'border-red-600' : ''].join(' ')} />
{#if heightError}<span class="text-xs text-red-400">{heightError}</span>{/if}
</div>
</div>
<!-- Icon -->
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-icon">{glob.t('vcen.editor.view.icon')} <span class="text-gray-700">{glob.t('vcen.editor.view.optional')}</span></label>
<input id="vp-view-icon" type="text"
value={view.icon ?? ''}
oninput={e => onViewUpdate({ icon: e.currentTarget.value || undefined })}
placeholder="car-front, image, eye…"
class="field-input text-xs font-mono" />
</div>
<!-- Strategy + config inline -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-strategy">{glob.t('vcen.editor.view.strategy')}</label>
<select
id="vp-view-strategy"
bind:value={selectedStrategy}
onchange={() => onViewUpdate({ config: defaultConfig(selectedStrategy) })}
class="field-input text-xs">
{#each STRATEGY_VALUES as s (s)}
<option value={s}>{STRATEGY_LABELS[s]}</option>
{/each}
</select>
</div>
{#if view.config.strategy === 'static'}
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-cfg-url">{glob.t('vcen.editor.view.url')}</label>
<input id="vp-cfg-url" type="text"
value={cfgGet(view.config, 'url')}
oninput={e => cfgSet('url', e.currentTarget.value)}
placeholder="images/front.jpg"
class="field-input font-mono text-xs" />
</div>
{:else if view.config.strategy === 'dynamic'}
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.template')}</span>
<TemplateBuilder
value={cfgGet(view.config, 'template')}
attrs={templateAttrs}
onUpdate={setViewTemplate} />
</div>
{:else if view.config.strategy === 'api'}
<div></div>
{:else if view.config.strategy === 'd3'}
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-cfg-modelurl">{glob.t('vcen.editor.view.modelUrl')}</label>
<input id="vp-cfg-modelurl" type="text"
value={cfgGet(view.config, 'modelUrl')}
oninput={e => cfgSet('modelUrl', e.currentTarget.value)}
placeholder="models/object.glb"
class="field-input font-mono text-xs" />
</div>
{:else}
<div></div>
{/if}
</div>
<!-- ── Editor API (strategy = 'api') ─────────────────────────── -->
{#if view.config.strategy === 'api'}
{@const apiCfg = getApiCfg()}
{@const apiMethod = apiCfg.method ?? 'GET'}
<div class="flex flex-col gap-2">
<!-- Método -->
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-api-method">{glob.t('vcen.editor.view.method')}</label>
<select
id="vp-api-method"
bind:value={selectedApiMethod}
onchange={setApiMethod}
class="field-input text-xs w-28">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<!-- Endpoint -->
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.endpoint')}</span>
<TemplateBuilder
value={apiCfg.endpoint}
attrs={templateAttrs}
onUpdate={setApiEndpointTemplate} />
</div>
<!-- Parámetros (solo POST) -->
{#if apiMethod === 'POST'}
<div class="flex items-center justify-between">
<span class="ve-label">{glob.t('vcen.editor.view.params')}</span>
<button
onclick={addApiParam}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors font-medium">
{glob.t('vcen.editor.view.addParam')}
</button>
</div>
{#if (apiCfg.params?.length ?? 0) === 0}
<p class="text-xs text-gray-700 italic px-1">{glob.t('vcen.editor.view.noParams')}</p>
{:else}
{#each (apiCfg.params ?? []) as param, pi (pi)}
<div class="border border-gray-800 rounded-lg p-3 flex flex-col gap-2 bg-gray-900/30">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">
{glob.t('vcen.editor.view.paramN', { n: pi + 1 })}
</span>
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteParam')}
onconfirm={() => removeApiParam(pi)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-api-key-{pi}">{glob.t('vcen.editor.view.paramKey')}</label>
<input id="vp-api-key-{pi}" type="text"
value={param.key}
oninput={e => updateApiParam(pi, e.currentTarget.value)}
placeholder="color, trim, env…"
class="field-input font-mono text-xs" />
</div>
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.paramVal')}</span>
<TemplateBuilder
value={param.value}
attrs={templateAttrs}
onUpdate={tpl => setApiParamTemplate(pi, tpl)} />
</div>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
<!-- ── Editor de capas (strategy = 'layers') ──────────────────── -->
{#if view.config.strategy === 'layers'}
{@const layersCfg = getLayersCfg()}
{@const layers = layersCfg.layers ?? []}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="ve-label">{glob.t('vcen.editor.view.layers')}</span>
<button
onclick={addLayer}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors font-medium">
{glob.t('vcen.editor.view.addLayer')}
</button>
</div>
{#if layers.length === 0}
<p class="text-xs text-gray-700 italic px-1">{glob.t('vcen.editor.view.noLayersHint')}</p>
{:else}
{#each layers as layer, li (li)}
<div class="border border-gray-800 rounded-lg p-3 flex flex-col gap-2 bg-gray-900/30">
<!-- Cabecera capa -->
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">
{glob.t('vcen.editor.view.layerN', { n: li + 1 })}
</span>
<div class="flex items-center gap-0.5">
<button
onclick={() => moveLayer(li, 'up')}
disabled={li === 0}
title={glob.t('vcen.editor.sidebar.moveUp')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none">↑</button>
<button
onclick={() => moveLayer(li, 'down')}
disabled={li === layers.length - 1}
title={glob.t('vcen.editor.sidebar.moveDown')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none">↓</button>
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteLayer')}
confirmBody={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
onconfirm={() => removeLayer(li)}
/>
</div>
</div>
<!-- attId + template -->
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-layer-attid-{li}">{glob.t('vcen.editor.view.mainAtt')}</label>
{#if templateAttrs.length > 0}
<select
id="vp-layer-attid-{li}"
onchange={(e) => updateLayer(li, { attId: toAttID(e.currentTarget.value) })}
class="field-input text-xs font-mono">
<option value="">{glob.t('vcen.editor.view.selectAtt')}</option>
{#each templateAttrs as a (a.path)}
<option value={a.path} selected={layer.attId === a.path}>{a.label} ({a.path})</option>
{/each}
</select>
{:else}
<input id="vp-layer-attid-{li}" type="text"
value={layer.attId}
oninput={e => updateLayer(li, { attId: toAttID(e.currentTarget.value) })}
placeholder="at:color…"
class="field-input font-mono text-xs" />
{/if}
</div>
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.imageTemplate')}</span>
<TemplateBuilder
value={layer.template}
attrs={templateAttrs}
onUpdate={tpl => setLayerTemplate(li, tpl)} />
</div>
</div>
<!-- zIndex + opcional -->
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-layer-zi-{li}">z-Index</label>
<input id="vp-layer-zi-{li}" type="number"
value={layer.zIndex ?? ''}
oninput={e => updateLayer(li, { zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="0, 10, 20…"
class="field-input text-xs" />
</div>
<div class="flex items-center gap-2 pt-5">
<input type="checkbox"
id="vp-layer-opt-{li}"
checked={layer.optional ?? false}
onchange={e => updateLayer(li, { optional: e.currentTarget.checked || undefined })} />
<label class="text-xs text-gray-400 cursor-pointer select-none" for="vp-layer-opt-{li}">
{glob.t('vcen.editor.view.optional')}
</label>
</div>
</div>
<!-- Condición (ConditionBuilder) -->
<div class="border-t border-gray-800/50 pt-2">
<button
onclick={() => condOpenLayer === li ? closeLayerCond() : openLayerCond(li, layer)}
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider
text-gray-600 hover:text-gray-400 transition-colors w-full text-left">
<svg class="h-2.5 w-2.5 transition-transform fill-current {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8">
<polygon points="2,1 7,4 2,7"/>
</svg>
{glob.t('vcen.editor.view.condition')}
{#if layer.condition}
<span class="ml-auto text-amber-600 normal-case tracking-normal">{glob.t('vcen.editor.view.condActive')}</span>
{:else}
<span class="ml-auto normal-case tracking-normal text-gray-700 italic">{glob.t('vcen.editor.view.condAlways')}</span>
{/if}
</button>
{#if condOpenLayer === li}
<div class="mt-2 rounded border border-gray-800 p-2 bg-gray-900/60">
<ConditionBuilder store={condStore} onchange={onLayerCondChange} />
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>

@ -0,0 +1,72 @@
<script lang="ts">
import type { Visual, ViewID } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import {getContext} from "svelte";
let {
visual,
catDefaults = {},
views = [],
onUpdate,
}: {
visual: Visual | undefined
catDefaults?: { basePath?: string; fallback?: string }
views: { id: ViewID; label: Record<string, string>; code: string }[]
onUpdate: (patch: Partial<Visual>) => void
} = $props()
const glob = $derived(getContext<EditorGlob>('editor-glob'))
// Local state for select
let selectedDefaultView = $state('')
// Sync when prop changes
$effect(() => {
selectedDefaultView = visual?.defaultViewId ?? ''
})
function toOptViewId(s: string): ViewID | undefined {
return (s || undefined) as ViewID | undefined
}
</script>
<div class="ve-panel-content">
<h3 class="section-title">{glob.t('vcen.editor.view.generalConfig')}</h3>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-basepath">Base path <span class="text-gray-700">(override)</span></label>
<input id="vp-basepath" type="text"
value={visual?.basePath ?? ''}
oninput={e => onUpdate({ basePath: e.currentTarget.value || undefined })}
placeholder={catDefaults.basePath || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-fallback">Fallback <span class="text-gray-700">(override)</span></label>
<input id="vp-fallback" type="text"
value={visual?.fallback ?? ''}
oninput={e => onUpdate({ fallback: e.currentTarget.value || undefined })}
placeholder={catDefaults.fallback || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
{#if views.length > 0}
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-default-view">{glob.t('vcen.editor.view.defaultView')}</label>
<select
id="vp-default-view"
bind:value={selectedDefaultView}
onchange={() => onUpdate({ defaultViewId: toOptViewId(selectedDefaultView) })}
class="field-input text-xs">
<option value="">{glob.t('vcen.editor.view.firstView')}</option>
{#each views as v (v.id)}
<option value={v.id}>{v.label || v.code}</option>
{/each}
</select>
</div>
{:else}
<p class="text-xs text-gray-700 italic">{glob.t('vcen.editor.view.addFromSidebar')}</p>
{/if}
</div>

@ -0,0 +1,281 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { View, ViewResolution, Hotspot, ViewTemplate } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { HOTSPOT_DEFAULT_RADIUS } from '@/vcen-edit/lib/constants'
let {
previewView,
selectedHotspot,
pickingPos = false,
previewResolution,
previewResolvedUrl,
previewResolvedLayers,
previewStaticUrl,
previewDynTpl,
previewApiEp,
previewD3Model,
previewLayers,
onPreviewClick,
}: {
previewView: View | null
selectedHotspot: Hotspot | null
pickingPos?: boolean
previewResolution: ViewResolution | null
previewResolvedUrl: string
previewResolvedLayers: { url: string; zIndex: number }[]
previewStaticUrl: string
previewDynTpl: string
previewApiEp: string
previewD3Model: string
previewLayers: { template: ViewTemplate; zIndex?: number; condition?: unknown; optional?: boolean }[]
onPreviewClick: (e: MouseEvent) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
// Helper para mostrar ViewTemplate como string
function templateDisplay(tpl: ViewTemplate): string {
if (!tpl) return ''
if (typeof tpl === 'string') return tpl
if (tpl.length === 0) return ''
if (tpl.length === 1 && tpl[0].kind === 'text') return tpl[0].value
return JSON.stringify(tpl)
}
const STRATEGY_LABELS = $derived<Record<string, string>>({
static: glob.t('vcen.editor.view.strategies.static'),
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
layers: glob.t('vcen.editor.view.strategies.layers'),
api: glob.t('vcen.editor.view.strategies.api'),
d3: glob.t('vcen.editor.view.strategies.d3'),
})
const previewStrategy = $derived(previewView?.config.strategy ?? null)
let previewImgError = $state(false)
let previewImgLoaded = $state(false)
// Resetear estado cuando cambia la URL
$effect(() => {
void previewResolvedUrl
void previewStaticUrl
void previewApiEp
previewImgError = false
previewImgLoaded = false
})
</script>
<div class="flex-1 w-[50%] min-w-0 border-l border-gray-800 flex flex-col overflow-y-auto">
<div class="px-3 py-2 border-b border-gray-800 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Preview</span>
{#if previewStrategy}
<span class="text-[10px] text-gray-700 font-mono">{STRATEGY_LABELS[previewStrategy] ?? previewStrategy}</span>
{/if}
</div>
<div class="p-3 flex flex-col gap-3">
<!-- ── Contenedor visual interactivo ──────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative w-full rounded border border-gray-800 bg-gray-900 overflow-hidden
{pickingPos ? 'ring-2 ring-indigo-500/60' : ''}"
style="aspect-ratio: {previewView?.width || 4} / {previewView?.height || 3};
cursor: {pickingPos ? 'crosshair' : 'default'}"
onclick={onPreviewClick}
>
{#if previewStrategy === 'static' && previewStaticUrl}
<!-- Imagen estática (URL directa del config) -->
{#if !previewImgError}
<img
src={previewStaticUrl}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewStaticUrl}</span>
</div>
{/if}
{:else if previewStrategy === 'dynamic' && previewResolvedUrl}
<!-- Imagen dinámica resuelta -->
{#if !previewImgError}
<img
src={previewResolvedUrl}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewResolvedUrl}</span>
</div>
{/if}
{#if previewResolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium">
fallback
</div>
{/if}
{:else if previewStrategy === 'layers' && previewResolvedLayers.length > 0}
<!-- Capas resueltas: imágenes apiladas -->
{#each previewResolvedLayers as layer, li (li)}
<img
src={layer.url}
alt={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
style="z-index: {layer.zIndex}"
/>
{/each}
{#if previewResolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium z-50">
fallback
</div>
{/if}
{:else if previewStrategy === 'layers' && previewLayers.length > 0}
<!-- Capas sin resolver: placeholder -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
<div class="flex flex-col items-center gap-1">
{#each previewLayers as layer, li (li)}
<div class="flex items-center gap-2 px-2 py-1 rounded bg-gray-800/60 border border-gray-700 w-full max-w-48">
<span class="text-[10px] font-mono text-indigo-400 shrink-0">z{layer.zIndex ?? 0}</span>
<span class="text-[10px] font-mono text-gray-500 truncate flex-1">{templateDisplay(layer.template) || '—'}</span>
{#if layer.condition}
<span class="text-[10px] text-amber-600 shrink-0" title={glob.t('vcen.editor.view.condActive')}>if</span>
{/if}
{#if layer.optional}
<span class="text-[10px] text-gray-700 shrink-0">opt</span>
{/if}
</div>
{/each}
</div>
<span class="text-[10px] text-gray-700">{previewLayers.length} capa{previewLayers.length !== 1 ? 's' : ''}</span>
</div>
{:else if previewStrategy === 'dynamic'}
<!-- Template dinámico sin resolver -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
{#if previewDynTpl}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{previewDynTpl}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noTemplate')}</span>
{/if}
</div>
{:else if previewStrategy === 'api'}
{#if previewApiEp && !previewImgError}
<!-- Imagen vía API endpoint -->
<img
src={previewApiEp}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else if previewApiEp && previewImgError}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewApiEp}</span>
</div>
{:else}
<!-- Sin endpoint -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 12h-4m-2-7 2 7-2 7M3 12h4m2-7-2 7 2 7"/>
</svg>
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noEndpoint')}</span>
</div>
{/if}
{:else if previewStrategy === 'd3'}
<!-- Modelo 3D -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
{#if previewD3Model}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{previewD3Model}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noModel')}</span>
{/if}
</div>
{:else}
<!-- Sin imagen / placeholder genérico -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
<span class="text-[10px]">{pickingPos ? glob.t('vcen.editor.hotspot.clickToPosition') : glob.t('vcen.editor.view.noImage')}</span>
</div>
{/if}
<!-- Hotspot markers overlay -->
{#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 ?? HOTSPOT_DEFAULT_RADIUS) * 2}
<div
class="absolute rounded-full border-2 transition-all pointer-events-none
{isActive ? 'border-white shadow-lg z-10' : 'border-white/50'}"
style="
left: {mX}%;
top: {mY}%;
width: {size}px;
height: {size}px;
transform: translate(-50%, -50%){isActive ? ' scale(1.25)' : ''};
background-color: {marker.color ?? '#4f46e5'};
opacity: {isActive ? 1 : 0.7};
z-index: {marker.zIndex ?? 1};
"
title={getLabelStr(marker.label) || marker.id}
></div>
{/each}
</div>
<!-- Info inferior -->
<div class="flex flex-col gap-1 text-[10px] text-gray-700 font-mono">
<div class="flex items-center justify-between">
<span>{previewView?.width || '?'} × {previewView?.height || '?'} px</span>
{#if pickingPos}
<span class="text-indigo-400 font-sans font-medium">{glob.t('vcen.editor.hotspot.clickToPosition')}</span>
{/if}
</div>
{#if previewResolvedUrl || (previewStrategy === 'static' && previewStaticUrl)}
<span class="text-gray-600 break-all">{previewResolvedUrl || previewStaticUrl}</span>
{/if}
</div>
</div>
</div>

@ -0,0 +1,133 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { View, ViewID, Hotspot, HotspotID } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
type Sel =
| 'general'
| { kind: 'view'; viewId: ViewID }
| { kind: 'hotspot'; viewId: ViewID; hotspotId: HotspotID }
let {
views,
selected,
onSelect,
onAddView,
onAddHotspot,
onMoveView,
}: {
views: View[]
selected: Sel
onSelect: (sel: Sel) => void
onAddView: () => void
onAddHotspot: (viewId: ViewID) => void
onMoveView: (viewId: ViewID, dir: 'up' | 'down') => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
// Derived helpers para evitar casts en template
const selKind = $derived(selected === 'general' ? null : selected.kind)
const selViewId = $derived(selected === 'general' ? null : (selected as any).viewId)
const selHotspotId = $derived(selected !== 'general' && (selected as any).kind === 'hotspot' ? (selected as any).hotspotId : null)
</script>
<div class="w-44 shrink-0 border-r border-gray-800 flex flex-col overflow-y-auto py-2">
<!-- General -->
<button
onclick={() => onSelect('general')}
class="flex items-center gap-1.5 px-3 py-2 text-sm cursor-pointer select-none transition-colors border-l-2 w-full
{selected === 'general'
? 'bg-gray-800/70 text-gray-100 border-indigo-500'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800/40 border-transparent'}">
<svg class="h-3 w-3 shrink-0 fill-current" viewBox="0 0 12 12" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4"/>
</svg>
{glob.t('vcen.editor.view.general')}
</button>
<!-- Vistas header -->
<div class="px-3 pt-3 pb-1 mt-1 mb-0.5 flex items-center justify-between border-t border-gray-800/60">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">{glob.t('vcen.editor.view.viewsSection')}</span>
<button
onclick={onAddView}
class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none"
title={glob.t('vcen.editor.view.newViewBtn')}>+</button>
</div>
{#if views.length === 0}
<div class="pl-7 pr-3 py-1.5 text-[11px] text-gray-700 italic">{glob.t('vcen.editor.view.noViews')}</div>
{:else}
{#each views as view, idx (view.id)}
{@const isViewSel = selViewId === view.id}
{@const isViewActive = isViewSel && selKind === 'view'}
<!-- View row -->
<div class="group/view w-full flex items-center border-l-2
{isViewActive
? 'bg-gray-800/70 border-indigo-500'
: isViewSel
? 'border-indigo-500/30'
: 'border-transparent hover:bg-gray-800/40'}">
<button
onclick={() => onSelect({ kind: 'view', viewId: view.id })}
class="flex-1 min-w-0 pl-3 pr-1 py-1.5 text-left select-none transition-colors">
<span class="block text-sm truncate
{isViewActive ? 'text-gray-100' : 'text-gray-400 group-hover/view:text-gray-100'}">
{getLabelStr(view.label) || view.code}
</span>
</button>
<!-- Botón añadir hotspot (siempre visible) + move buttons (hover) -->
<div class="flex items-center shrink-0 pr-1 gap-0.5">
<div class="hidden group-hover/view:flex items-center gap-0.5">
<button
onclick={e => { e.stopPropagation(); onMoveView(view.id, 'up') }}
disabled={idx === 0}
title={glob.t('vcen.editor.sidebar.moveUp')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none"
>↑</button>
<button
onclick={e => { e.stopPropagation(); onMoveView(view.id, 'down') }}
disabled={idx === views.length - 1}
title={glob.t('vcen.editor.sidebar.moveDown')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none"
>↓</button>
</div>
<button
onclick={e => { e.stopPropagation(); onAddHotspot(view.id) }}
title={glob.t('vcen.editor.hotspot.newTitle')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-indigo-400 hover:bg-gray-700 transition-colors"
>+</button>
</div>
</div>
{#each (view.hotspots ?? []) as hs (hs.id)}
{@const isHsSel = selHotspotId === hs.id && selViewId === view.id}
{@const hsLabel = getLabelStr(hs.label)}
<div class="w-full border-l-2
{isHsSel
? 'bg-gray-800/70 border-indigo-500'
: 'border-transparent hover:bg-gray-800/40'}">
<button
onclick={() => onSelect({ kind: 'hotspot', viewId: view.id, hotspotId: hs.id })}
class="w-full pl-7 pr-3 py-1 text-left select-none transition-colors">
<span class="block text-sm truncate
{isHsSel ? 'text-gray-100' : 'text-gray-400 hover:text-gray-100'}">
{hsLabel || glob.t('vcen.editor.hotspot.noLabel')}
</span>
</button>
</div>
{/each}
{/each}
{/if}
</div>

@ -0,0 +1,140 @@
// ─────────────────────────────────────────────────────────────────────────────
// CLONE - vcen-edit/lib/clone.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Funciones de clonación eficiente para el editor de catálogos.
* Reemplaza JSON.stringify/parse por clonación manual más eficiente.
*/
import type { Cat, Obj, Sec, View, Hotspot, Att, Opt, OptCategory } from '@/vcen/lib';
/**
* Clona un objeto Cat de forma más eficiente que JSON.stringify/parse
*/
export function cloneCat(cat: Cat): Cat {
return {
...cat,
atts: cat.atts.map(att => cloneAtt(att)),
objs: cat.objs.map(obj => cloneObj(obj)),
opts: cat.opts.map(opt => cloneOpt(opt)),
optCategories: cat.optCategories.map(oc => ({ ...oc })),
};
}
/**
* Clona un objeto Obj
*/
export function cloneObj(obj: Obj): Obj {
return {
...obj,
atts: obj.atts.map(att => cloneAtt(att)),
secs: obj.secs.map(sec => cloneSec(sec)),
};
}
/**
* Clona un objeto Sec
*/
export function cloneSec(sec: Sec): Sec {
return {
...sec,
atts: sec.atts.map(att => cloneAtt(att)),
visual: sec.visual ? cloneVisual(sec.visual) : undefined,
};
}
/**
* Clona un objeto Att
*/
export function cloneAtt(att: Att): Att {
const attValue = att.value as any;
const attDomain = attValue.domain as any;
return {
...att,
value: {
...attValue,
domain: { ...attDomain }
}
} as Att;
}
/**
* Clona una Vista
*/
export function cloneView(view: View): View {
return {
...view,
hotspots: view.hotspots?.map(hs => cloneHotspot(hs)),
};
}
/**
* Clona un Hotspot
*/
export function cloneHotspot(hotspot: Hotspot): Hotspot {
return { ...hotspot };
}
/**
* Clona una Opción
*/
export function cloneOpt(opt: Opt): Opt {
const optValue = opt.value as any;
const optDomain = optValue.domain as any;
return {
...opt,
value: {
...optValue,
domain: { ...optDomain }
}
} as Opt;
}
/**
* Clona una etiqueta LingRecord
*/
export function cloneLabel(label: Record<string, string>): Record<string, string> {
return { ...label };
}
/**
* Clona un objeto Visual (puede contener views)
*/
function cloneVisual(visual: any): any {
return {
...visual,
views: visual.views?.map((v: View) => cloneView(v)),
};
}
/**
* Clona cualquier valor usando spread operator para objetos primitivos
*/
export function shallowClone<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return [...obj] as unknown as T;
return { ...obj } as unknown as T;
}
/**
* Clona un array manteniendo sus elementos
*/
export function cloneArray<T>(array: T[]): T[] {
return [...array];
}
/**
* Clona un Map manteniendo sus entradas
*/
export function cloneMap<K, V>(map: Map<K, V>): Map<K, V> {
return new Map(map);
}
/**
* Clona un Set manteniendo sus elementos
*/
export function cloneSet<T>(set: Set<T>): Set<T> {
return new Set(set);
}

@ -454,6 +454,9 @@ export function validateCondition(
function walk(n: ConditionNode) {
if (n.kind === 'logic') {
if (n.children.length === 0) {
errors.push({ nodeId: n.id, message: `LogicNode "${n.op}" debe tener al menos un hijo` });
}
n.children.forEach(walk);
return;
}

@ -0,0 +1,169 @@
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS - vcen-edit/lib/constants.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Constantes de dominio del editor de catálogos.
* Centraliza magic numbers y valores hardcoded para mejor mantenibilidad.
*/
// ─────────────────────────────────────────────────────────────────────────────
// VIEW DIMENSIONS
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_DEFAULT_WIDTH = 800;
export const VIEW_DEFAULT_HEIGHT = 600;
export const VIEW_MIN_WIDTH = 100;
export const VIEW_MAX_WIDTH = 4000;
export const VIEW_MIN_HEIGHT = 100;
export const VIEW_MAX_HEIGHT = 4000;
// ─────────────────────────────────────────────────────────────────────────────
// RULE CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const RULE_DEFAULT_PRIORITY = 10;
export const RULE_MIN_PRIORITY = 1;
export const RULE_MAX_PRIORITY = 100;
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const HOTSPOT_DEFAULT_RADIUS = 8;
export const HOTSPOT_MIN_RADIUS = 4;
export const HOTSPOT_MAX_RADIUS = 20;
export const HOTSPOT_MIN_POSITION = 0;
export const HOTSPOT_MAX_POSITION = 100;
export const HOTSPOT_MAX_Z_INDEX = 1000;
// ─────────────────────────────────────────────────────────────────────────────
// VIEW TYPES
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_TYPES = [
'front',
'side',
'rear',
'top',
'isometric',
'panoramic',
'detail',
'blueprint',
'custom'
] as const;
export type ViewType = typeof VIEW_TYPES[number];
// ─────────────────────────────────────────────────────────────────────────────
// VIEW STRATEGIES
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_STRATEGIES = [
'static',
'dynamic',
'layers',
'api',
'd3'
] as const;
export type ViewStrategy = typeof VIEW_STRATEGIES[number];
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT MODES
// ─────────────────────────────────────────────────────────────────────────────
export const HOTSPOT_MODES = [
'tooltip',
'popup'
] as const;
export type HotspotMode = typeof HOTSPOT_MODES[number];
// ─────────────────────────────────────────────────────────────────────────────
// DOMAIN DEFAULT VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const DOMAIN_DEFAULT_NUMBER = 0;
export const DOMAIN_DEFAULT_BOOLEAN = false;
export const DOMAIN_DEFAULT_STRING = '';
// ─────────────────────────────────────────────────────────────────────────────
// UI LIMITS
// ─────────────────────────────────────────────────────────────────────────────
export const UI_MAX_ID_LENGTH = 50;
export const UI_MAX_CODE_LENGTH = 100;
export const UI_MAX_LABEL_LENGTH = 200;
export const UI_MAX_DESC_LENGTH = 1000;
// ─────────────────────────────────────────────────────────────────────────────
// SIDEBAR CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const SIDEBAR_MIN_WIDTH = 160;
export const SIDEBAR_MAX_WIDTH = 480;
export const SIDEBAR_DEFAULT_WIDTH = 320;
// ─────────────────────────────────────────────────────────────────────────────
// Z-INDEX VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const Z_INDEX_BACKGROUND = 0;
export const Z_INDEX_BASE = 1;
export const Z_INDEX_OVERLAY = 10;
export const Z_INDEX_MODAL = 100;
export const Z_INDEX_DROPDOWN = 200;
export const Z_INDEX_TOAST = 300;
// ─────────────────────────────────────────────────────────────────────────────
// COLOR VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const COLOR_DEFAULT_HOTSPOT = '#4f46e5';
export const COLOR_DEFAULT_PRIMARY = '#3b82f6';
export const COLOR_DEFAULT_SUCCESS = '#10b981';
export const COLOR_DEFAULT_WARNING = '#f59e0b';
export const COLOR_DEFAULT_ERROR = '#ef4444';
// ─────────────────────────────────────────────────────────────────────────────
// ANIMATION DURATIONS
// ─────────────────────────────────────────────────────────────────────────────
export const ANIMATION_DURATION_FAST = 150;
export const ANIMATION_DURATION_NORMAL = 300;
export const ANIMATION_DURATION_SLOW = 600;
// ─────────────────────────────────────────────────────────────────────────────
// SEQUENCE GENERATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Prefijos de ID para generación de IDs únicos
*/
export const ID_PREFIXES = {
CATALOG: 'ct',
OBJECT: 'ob',
SECTION: 'sc',
ATTRIBUTE: 'at',
OPTION: 'op',
RULE: 'rl',
VIEW: 'vw',
HOTSPOT: 'hs',
OPT_CATEGORY: 'oc'
} as const;
// ─────────────────────────────────────────────────────────────────────────────
// FORM VALIDATION LIMITS
// ─────────────────────────────────────────────────────────────────────────────
export const VALIDATION_MIN_CODE_LENGTH = 1;
export const VALIDATION_MAX_CODE_LENGTH = 50;
export const VALIDATION_MIN_NAME_LENGTH = 1;
export const VALIDATION_MAX_NAME_LENGTH = 200;
// ─────────────────────────────────────────────────────────────────────────────
// CACHE CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const CACHE_MAX_SIZE = 50;
export const CACHE_TTL_MS = 30000; // 30 segundos

@ -0,0 +1,268 @@
// ─────────────────────────────────────────────────────────────────────────────
// TYPE GUARDS - vcen-edit/lib/guards.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Type guards específicos para el editor de catálogos vcen-edit.
* Proporciona validación adicional para estructuras usadas en la UI del editor.
*/
import type { AttDomain, AttValue, ViewConfig, Visual, Hotspot } from '@/vcen/lib';
// ─────────────────────────────────────────────────────────────────────────────
// ATTRIBUTE VALUE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar AttValue
*/
export function isAttValue(value: unknown): value is AttValue {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return 'value' in v && 'domain' in v;
}
/**
* Guard para validar que un AttDomain tiene modo 'fixed'
*/
export function isAttDomainWithFixedMode(value: unknown): value is AttDomain & { mode: 'fixed'; fixed?: string | number | boolean } {
if (typeof value !== 'object' || value === null) return false;
const d = value as Record<string, unknown>;
return typeof d.kind === 'string' &&
d.mode === 'fixed' &&
(d.fixed === undefined ||
typeof d.fixed === 'string' ||
typeof d.fixed === 'number' ||
typeof d.fixed === 'boolean');
}
/**
* Guard para validar AttDomain con modo específico
*/
export function isAttDomainWithMode<K extends AttDomain['mode']>(
value: unknown,
mode: K
): value is AttDomain & { mode: K } {
if (typeof value !== 'object' || value === null) return false;
const d = value as AttDomain;
return d.mode === mode;
}
// ─────────────────────────────────────────────────────────────────────────────
// VIEW EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar ViewConfig con estrategia 'static'
*/
export function isViewConfigStatic(value: unknown): value is ViewConfig & { strategy: 'static'; url: string } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'static' && 'url' in config && typeof config.url === 'string';
}
/**
* Guard para validar ViewConfig con estrategia 'dynamic'
*/
export function isViewConfigDynamic(value: unknown): value is ViewConfig & { strategy: 'dynamic'; template: string | object } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'dynamic' && 'template' in config;
}
/**
* Guard para validar ViewConfig con estrategia 'layers'
*/
export function isViewConfigLayers(value: unknown): value is ViewConfig & { strategy: 'layers'; layers: any[] } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'layers' && 'layers' in config && Array.isArray(config.layers);
}
/**
* Guard para validar ViewConfig con estrategia 'api'
*/
export function isViewConfigApi(value: unknown): value is ViewConfig & { strategy: 'api'; endpoint: string | object } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'api' && 'endpoint' in config;
}
/**
* Guard para validar ViewConfig con estrategia 'd3'
*/
export function isViewConfigD3(value: unknown): value is ViewConfig & { strategy: 'd3'; modelUrl: string } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'd3' && 'modelUrl' in config && typeof config.modelUrl === 'string';
}
// ─────────────────────────────────────────────────────────────────────────────
// VISUAL EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Visual con vistas
*/
export function isVisualWithViews(value: unknown): value is Visual & { views: any[] } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'views' in visual && Array.isArray(visual.views);
}
/**
* Guard para validar Visual con basePath
*/
export function isVisualWithBasePath(value: unknown): value is Visual & { basePath: string } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'basePath' in visual && typeof visual.basePath === 'string';
}
/**
* Guard para validar Visual con fallback
*/
export function isVisualWithFallback(value: unknown): value is Visual & { fallback: string } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'fallback' in visual && typeof visual.fallback === 'string';
}
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Hotspot con targetAtt
*/
export function isHotspotWithTargetAtt(value: unknown): value is Hotspot & { targetAtt: string } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'targetAtt' in hotspot && typeof hotspot.targetAtt === 'string';
}
/**
* Guard para validar Hotspot con tooltip
*/
export function isHotspotWithTooltip(value: unknown): value is Hotspot & { tooltip: Record<string, string> } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'tooltip' in hotspot &&
typeof hotspot.tooltip === 'object' &&
hotspot.tooltip !== null;
}
/**
* Guard para validar Hotspot con condición de visibilidad
*/
export function isHotspotWithVisibilityCondition(value: unknown): value is Hotspot & { visible: boolean | object } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'visible' in hotspot &&
(typeof hotspot.visible === 'boolean' ||
(typeof hotspot.visible === 'object' && hotspot.visible !== null));
}
/**
* Guard para validar Hotspot con color
*/
export function isHotspotWithColor(value: unknown): value is Hotspot & { color: string } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'color' in hotspot && typeof hotspot.color === 'string';
}
/**
* Guard para validar Hotspot con zIndex
*/
export function isHotspotWithZIndex(value: unknown): value is Hotspot & { zIndex: number } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'zIndex' in hotspot && typeof hotspot.zIndex === 'number';
}
// ─────────────────────────────────────────────────────────────────────────────
// UI STATE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar selección de vista en el editor visual
*/
export interface VisualSelection {
kind: 'general' | 'view' | 'hotspot';
viewId?: string;
hotspotId?: string;
}
export function isVisualSelection(value: unknown): value is VisualSelection {
if (typeof value !== 'object' || value === null) return false;
const sel = value as Record<string, unknown>;
return typeof sel.kind === 'string' &&
['general', 'view', 'hotspot'].includes(sel.kind);
}
/**
* Guard para validar selección de vista específica
*/
export function isViewSelection(value: unknown): value is VisualSelection & { kind: 'view'; viewId: string } {
if (!isVisualSelection(value)) return false;
return value.kind === 'view' && typeof value.viewId === 'string';
}
/**
* Guard para validar selección de hotspot específico
*/
export function isHotspotSelection(value: unknown): value is VisualSelection & { kind: 'hotspot'; viewId: string; hotspotId: string } {
if (!isVisualSelection(value)) return false;
return value.kind === 'hotspot' &&
typeof value.viewId === 'string' &&
typeof value.hotspotId === 'string';
}
/**
* Guard para validar selección general (configuración del visual)
*/
export function isGeneralSelection(value: unknown): value is VisualSelection & { kind: 'general' } {
if (!isVisualSelection(value)) return false;
return value.kind === 'general';
}
// ─────────────────────────────────────────────────────────────────────────────
// FORM STATE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar estado de formulario con errores
*/
export interface FormErrors {
[key: string]: string | undefined;
}
export function isFormErrors(value: unknown): value is FormErrors {
if (typeof value !== 'object' || value === null) return false;
const errors = value as Record<string, unknown>;
return Object.values(errors).every(v => v === undefined || typeof v === 'string');
}
/**
* Guard para validar estado de formulario válido
*/
export function isFormValid(errors: FormErrors): boolean {
return Object.values(errors).every(v => !v);
}

@ -0,0 +1,425 @@
// ─────────────────────────────────────────────────────────────────────────────
// VALIDATION - vcen-edit/lib/validation.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Validaciones consolidadas para el editor de catálogos.
* Proporciona funciones de validación reutilizables para formularios y datos.
*/
// Reexportar validaciones existentes
import { isValidId as isValidIdBase } from './validate';
export const isValidId = isValidIdBase;
// ─────────────────────────────────────────────────────────────────────────────
// VIEW DIMENSIONS VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida dimensiones de vista (ancho/alto)
* Rango válido: 100-4000 píxeles
*/
export function isValidViewDimension(dimension: number): boolean {
return Number.isInteger(dimension) && dimension >= 100 && dimension <= 4000;
}
/**
* Valida ancho de vista específicamente
*/
export function isValidViewWidth(width: number): boolean {
return isValidViewDimension(width);
}
/**
* Valida alto de vista específicamente
*/
export function isValidViewHeight(height: number): boolean {
return isValidViewDimension(height);
}
/**
* Valida que un tamaño sea positivo y razonable (1-10000)
*/
export function isValidPositiveSize(size: number): boolean {
return Number.isInteger(size) && size > 0 && size <= 10000;
}
// ─────────────────────────────────────────────────────────────────────────────
// RULE PRIORITY VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida prioridad de regla (1-100)
*/
export function isValidPriority(priority: number): boolean {
return Number.isInteger(priority) && priority >= 1 && priority <= 100;
}
/**
* Valida que una prioridad sea mayor que otra
*/
export function isHigherPriority(current: number, target: number): boolean {
return current > target;
}
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida posición de hotspot (0-100 para porcentajes, o píxeles positivos)
*/
export function isValidHotspotPosition(position: number | string): boolean {
if (typeof position === 'number') {
return position >= 0 && position <= 10000; // Máximo 10000px
}
if (typeof position === 'string') {
const percentageMatch = position.match(/^(\d+)%$/);
if (percentageMatch) {
const value = parseInt(percentageMatch[1], 10);
return value >= 0 && value <= 100;
}
}
return false;
}
/**
* Valida posición X de hotspot específicamente
*/
export function isValidHotspotPosX(posX: number | string): boolean {
return isValidHotspotPosition(posX);
}
/**
* Valida posición Y de hotspot específicamente
*/
export function isValidHotspotPosY(posY: number | string): boolean {
return isValidHotspotPosition(posY);
}
/**
* Valida radio de hotspot (4-50 píxeles)
*/
export function isValidHotspotRadius(radius: number): boolean {
return Number.isInteger(radius) && radius >= 4 && radius <= 50;
}
/**
* Valida zIndex de hotspot (0-1000)
*/
export function isValidHotspotZIndex(zIndex: number): boolean {
return Number.isInteger(zIndex) && zIndex >= 0 && zIndex <= 1000;
}
// ─────────────────────────────────────────────────────────────────────────────
// COLOR VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida color hexadecimal
* Soporta formatos: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
*/
export function isValidHexColor(color: string): boolean {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(color);
}
/**
* Valida que una cadena sea un color CSS válido (hex, rgb, rgba, named color)
*/
export function isValidCSSColor(color: string): boolean {
if (isValidHexColor(color)) return true;
if (color.startsWith('rgb') || color.startsWith('hsl')) {
try {
const s = new Option().style;
s.color = color;
return s.color !== '';
} catch {
return false;
}
}
// Colores CSS nominales básicos
const cssColors = new Set([
'black', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta',
'gray', 'grey', 'silver', 'maroon', 'olive', 'lime', 'aqua', 'teal',
'navy', 'fuchsia', 'purple', 'orange', 'pink', 'brown'
]);
return cssColors.has(color.toLowerCase());
}
// ─────────────────────────────────────────────────────────────────────────────
// URL VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida URL
*/
export function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
/**
* Valida URL HTTP/HTTPS específicamente
*/
export function isHttpUrl(value: string): boolean {
if (!isValidUrl(value)) return false;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
/**
* Valida path de archivo (template de vista)
*/
export function isValidFilePath(value: string): boolean {
return value.length > 0 && value.indexOf('\0') === -1;
}
// ─────────────────────────────────────────────────────────────────────────────
// TEMPLATE VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida template de vista con referencias a atributos
* Formato: 'text_{path}more_text' o structured form
*/
export function isValidViewTemplate(template: string | object): boolean {
if (typeof template === 'string') {
// Validar template de string
// Acepta texto normal con {path} para referencias
return template.length > 0;
}
if (typeof template === 'object' && template !== null && Array.isArray(template)) {
// Validar template estructurado
return template.every(part => {
if (typeof part !== 'object' || part === null) return false;
const p = part as { kind: string; value?: string; path?: string };
return p.kind === 'text' && typeof p.value === 'string' && p.value.length > 0 ||
p.kind === 'att' && typeof p.path === 'string' && p.path.length > 0;
});
}
return false;
}
/**
* Extrae referencias a atributos de un template
*/
export function extractAttRefs(template: string | object): string[] {
const refs: string[] = [];
if (typeof template === 'string') {
// Buscar patrones {path} en string
const matches = template.matchAll(/\{([^\}]+)\}/g);
for (const match of matches) {
if (match[1]) refs.push(match[1]);
}
} else if (Array.isArray(template)) {
// Extraer de template estructurado
for (const part of template) {
if (typeof part === 'object' && part !== null) {
const p = part as { kind: string; path?: string };
if (p.kind === 'att' && p.path) {
refs.push(p.path);
}
}
}
}
return refs;
}
// ─────────────────────────────────────────────────────────────────────────────
// LABEL VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida que un label sea un string o LingRecord válido
*/
export function isValidLabel(value: unknown): boolean {
if (typeof value === 'string' && value.length > 0) return true;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Validar LingRecord
const keys = Object.keys(value);
return keys.length > 0 && keys.every(k => typeof (value as Record<string, unknown>)[k] === 'string');
}
return false;
}
/**
* Valida que un LingRecord tenga un idioma específico
*/
export function hasLabelLanguage(label: Record<string, string>, lang: string): boolean {
return lang in label && typeof label[lang] === 'string' && label[lang].length > 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// CODE VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida código de vista (alphanumeric con guiones y underscores)
*/
export function isValidViewCode(code: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(code) && code.length <= 100;
}
/**
* Valida código corto (para opciones, categorías, etc.)
*/
export function isValidShortCode(code: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(code) && code.length > 0 && code.length <= 50;
}
// ─────────────────────────────────────────────────────────────────────────────
// VALUE VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida valor numérico dentro de rango
*/
export function isValidNumericValue(value: number, min?: number, max?: number): boolean {
if (!Number.isFinite(value)) return false;
if (min !== undefined && value < min) return false;
if (max !== undefined && value > max) return false;
return true;
}
/**
* Valida valor de longitud de string
*/
export function isValidStringLength(value: string, min?: number, max?: number): boolean {
const len = value.length;
if (min !== undefined && len < min) return false;
if (max !== undefined && len > max) return false;
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// ARRAY VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Valida longitud de array
*/
export function isValidArrayLength<T>(array: T[], min?: number, max?: number): boolean {
const len = array.length;
if (min !== undefined && len < min) return false;
if (max !== undefined && len > max) return false;
return true;
}
/**
* Valida que un array no esté vacío
*/
export function isNonEmptyArray<T>(array: T[]): boolean {
return Array.isArray(array) && array.length > 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// FORM VALIDATION HELPERS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Tipo para resultado de validación de campo
*/
export interface FieldValidationResult {
valid: boolean;
error?: string;
}
/**
* Valida campo requerido
*/
export function validateRequired(value: string): FieldValidationResult {
if (!value || value.trim().length === 0) {
return { valid: false, error: 'Este campo es requerido' };
}
return { valid: true };
}
/**
* Valida ID (usando isValidId del módulo validate)
*/
export function validateId(id: string): FieldValidationResult {
if (!id || id.trim().length === 0) {
return { valid: false, error: 'ID requerido' };
}
if (!isValidId(id)) {
return { valid: false, error: 'ID inválido (solo minúsculas, números y guiones)' };
}
return { valid: true };
}
/**
* Valida dimensiones de vista
*/
export function validateViewDimension(dimension: string | number, fieldName = 'Dimensión'): FieldValidationResult {
const numValue = typeof dimension === 'string' ? parseInt(dimension, 10) : dimension;
if (!Number.isFinite(numValue)) {
return { valid: false, error: `${fieldName} debe ser un número` };
}
if (!isValidViewDimension(numValue)) {
return { valid: false, error: `${fieldName} debe estar entre 100 y 4000` };
}
return { valid: true };
}
/**
* Valida prioridad de regla
*/
export function validatePriority(priority: string | number): FieldValidationResult {
const numValue = typeof priority === 'string' ? parseInt(priority, 10) : priority;
if (!Number.isFinite(numValue)) {
return { valid: false, error: 'Prioridad debe ser un número' };
}
if (!isValidPriority(numValue)) {
return { valid: false, error: 'Prioridad debe estar entre 1 y 100' };
}
return { valid: true };
}
/**
* Valida color hexadecimal
*/
export function validateHexColor(color: string): FieldValidationResult {
if (!color || color.trim().length === 0) {
return { valid: false, error: 'Color requerido' };
}
if (!isValidHexColor(color)) {
return { valid: false, error: 'Color inválido (use formato #RGB, #RGBA, #RRGGBB o #RRGGBBAA)' };
}
return { valid: true };
}
// ─────────────────────────────────────────────────────────────────────────────
// ERROR MESSAGE HELPERS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Crea mensaje de error genérico
*/
export function createError(message: string): FieldValidationResult {
return { valid: false, error: message };
}
/**
* Crea resultado de validación exitoso
*/
export function createSuccess(): FieldValidationResult {
return { valid: true };
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,161 @@
// ─────────────────────────────────────────────────────────────────────────────
// EDITOR EFFECTS - vcen-edit/stores/editor-effects.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Efectos reactivos con memoization para el editor de catálogos.
* Optimiza la evaluación de reglas para evitar recálculos innecesarios.
*/
import { buildEvalCtx, evalRuleSetState } from '../lib/rule-eval';
import type { Cat, ObjID, SecID, AttState, Att } from '@/vcen/lib';
interface RuleEvaluationCache {
catCtx: Record<string, unknown>;
objCtx: Map<ObjID, Record<string, unknown>>;
secCtx: Map<SecID, Record<string, unknown>>;
timestamp: number;
}
let _ruleCache: RuleEvaluationCache | null = null;
let _lastCatHash: string = '';
/**
* Obtiene el valor efectivo de un atributo (maneja dominios fixed)
*/
export function attVal(att: Att): unknown {
const d = (att.value as any).domain as any;
return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value;
}
/**
* Calcula hash del catálogo para detectar cambios
* Hash simple basado en cantidad de elementos y sus IDs y valores
*/
function computeCatHash(cat: Cat): string {
// Hash simple basado en elementos principales
return JSON.stringify({
atts: cat.atts.map(a => ({ id: a.id, val: a.value.value })),
objs: cat.objs.map(o => ({
id: o.id,
atts: o.atts.map(a => ({ id: a.id, val: a.value.value })),
secs: o.secs.map(s => ({
id: s.id,
atts: s.atts.map(a => ({ id: a.id, val: a.value.value }))
}))
}))
});
}
/**
* Memoiza la evaluación de reglas para evitar recálculos innecesarios
*/
export function computeRuleStates(
cat: Cat,
prevObjStates: Map<ObjID, AttState | null>,
prevSecStates: Map<SecID, AttState | null>
): {
objStates: Map<ObjID, AttState | null>;
secStates: Map<SecID, AttState | null>;
} {
const currentHash = computeCatHash(cat);
// Si el catálogo no cambió, retornar estados anteriores
if (_ruleCache && _lastCatHash === currentHash) {
return { objStates: prevObjStates, secStates: prevSecStates };
}
// Construir contexto del catálogo
const catCtx: Record<string, unknown> = {};
for (const att of cat.atts) {
catCtx[att.id] = attVal(att);
}
const objStates = new Map<ObjID, AttState | null>();
const secStates = new Map<SecID, AttState | null>();
// Evaluar reglas de objetos
for (const obj of cat.objs) {
if (!obj.rules?.rules?.length) {
objStates.set(obj.id, null);
continue;
}
const ctx: Record<string, unknown> = { cat: catCtx };
// Añadir atributos del objeto al contexto
for (const a of obj.atts) {
ctx[a.id] = attVal(a);
}
// Añadir secciones y sus atributos al contexto
for (const sec of obj.secs) {
(ctx as any)[sec.id] = {};
for (const a of sec.atts) {
(ctx as any)[sec.id][a.id] = attVal(a);
}
}
objStates.set(obj.id, evalRuleSetState(obj.rules, ctx));
}
// Evaluar reglas de secciones
for (const obj of cat.objs) {
for (const sec of obj.secs) {
if (!sec.rules?.rules?.length) {
secStates.set(sec.id, null);
continue;
}
const ctx: Record<string, unknown> = { cat: catCtx };
// Añadir atributos del objeto al contexto
for (const a of obj.atts) {
ctx[a.id] = attVal(a);
}
// Añadir secciones y sus atributos al contexto
for (const s of obj.secs) {
(ctx as any)[s.id] = {};
for (const a of s.atts) {
(ctx as any)[s.id][a.id] = attVal(a);
}
}
secStates.set(sec.id, evalRuleSetState(sec.rules, ctx));
}
}
// Actualizar cache
_ruleCache = {
catCtx,
objCtx: new Map(),
secCtx: new Map(),
timestamp: Date.now()
};
_lastCatHash = currentHash;
return { objStates, secStates };
}
/**
* Limpia el caché de evaluación de reglas
*/
export function clearRuleCache(): void {
_ruleCache = null;
_lastCatHash = '';
}
/**
* Obtiene estadísticas del caché de reglas
*/
export function getRuleCacheStats(): {
cached: boolean;
lastHash: string;
timestamp: number | null;
} {
return {
cached: _ruleCache !== null,
lastHash: _lastCatHash,
timestamp: _ruleCache?.timestamp ?? null,
};
}

@ -0,0 +1,121 @@
/**
* Factories y valores por defecto para entidades del editor.
* @module vcen-edit/stores/editor-initialization
*/
import type { Att, AttValue, AttDomain, Sec, Obj, Opt, OptCategory, Rule, View, Hotspot, ViewID, HotspotID } from '@/vcen/lib';
import type { LingRecord } from '@/ling/lib';
import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util';
import { viewIDPrefix, hotspotIDPrefix } from '@/vcen/lib/types/view';
import { catOptIDPrefix } from '@/vcen/lib/types/opt';
import { VIEW_DEFAULT_WIDTH, VIEW_DEFAULT_HEIGHT, RULE_DEFAULT_PRIORITY } from '../lib/constants';
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
// ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now();
export function nextSeq(): string { return (++_seq).toString(36); }
export function toViewID(slug: string): ViewID { return `${viewIDPrefix}_${slug}` as ViewID; }
export function toHotspotID(slug: string): HotspotID { return `${hotspotIDPrefix}_${slug}` as HotspotID; }
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT VALUES
// ─────────────────────────────────────────────────────────────────────────────
export function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' };
}
export function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
const id = toAttID(slug ?? nextSeq());
return { id, label: { es: name }, value: defaultAttValue() };
}
export function defaultSec(name = 'Nueva sección', slug?: string): Sec {
const id = toSecID(slug ?? nextSeq());
return { id, label: { es: name }, atts: [] };
}
export function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
const id = toObjID(slug ?? nextSeq());
return { id, label: { es: name }, atts: [], secs: [] };
}
export function defaultOpt(value = ''): Opt {
return {
id: toOptID(nextSeq()),
value: { kind: 'string', value },
};
}
export function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: `${catOptIDPrefix}_${nextSeq()}`, name };
}
export function defaultRule(): Rule {
return {
id: toRuleID(nextSeq()),
priority: RULE_DEFAULT_PRIORITY,
condition: true,
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
export function defaultView(): View {
const id = toViewID(nextSeq());
return {
id,
label: { es: '' },
code: '',
viewType: 'front',
width: VIEW_DEFAULT_WIDTH,
height: VIEW_DEFAULT_HEIGHT,
config: { strategy: 'static', url: '' }
};
}
export function defaultHotspot(): Hotspot {
const id = toHotspotID(nextSeq());
return { id, label: { es: '' }, posX: 50, posY: 50, mode: 'tooltip' };
}
// ─────────────────────────────────────────────────────────────────────────────
// DOMAIN DEFAULTS
// ─────────────────────────────────────────────────────────────────────────────
export function defaultValueForDomain(domain: AttDomain): unknown {
switch (domain.kind) {
case 'string': return '';
case 'number': return 0;
case 'currency': return 0;
case 'boolean': return false;
case 'option': return null;
case 'date': return '';
case 'time': return '';
case 'datetime': return '';
}
}
// ─────────────────────────────────────────────────────────────────────────────
// LABEL HELPERS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Genera un label único con sufijo "-copy" (y "-copy-2", "-copy-3"…)
* comparando contra el conjunto de labels existentes en la lengua primaria.
*/
export function cloneLabel(label: LingRecord, existingLabels: LingRecord[], primaryLang: string): LingRecord {
const base = '-copy';
const rec = label as Record<string, string>;
for (let n = 1; ; n++) {
const suf = n === 1 ? base : `${base}-${n}`;
const cand = Object.fromEntries(
Object.entries(rec).map(([lang, val]) => [lang, `${val}${suf}`])
) as LingRecord;
const candRec = cand as Record<string, string>;
const conflict = existingLabels.some(e => (e as Record<string, string>)[primaryLang] === candRec[primaryLang]);
if (!conflict) return cand;
}
}

@ -0,0 +1,85 @@
/**
* Funciones de búsqueda para entidades del catálogo.
* @module vcen-edit/stores/editor-selectors
*/
import type { Cat, Obj, Sec, Att, View, Hotspot, ObjID, SecID, AttID, ViewID, HotspotID } from '@/vcen/lib';
// ─────────────────────────────────────────────────────────────────────────────
// CAT-LEVEL FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findCatAtt(cat: Cat, attId: AttID): Att | undefined {
return cat.atts.find(a => a.id === attId);
}
// ─────────────────────────────────────────────────────────────────────────────
// OBJ FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findObj(cat: Cat, objId: ObjID): Obj | undefined {
return cat.objs.find(o => o.id === objId);
}
export function findObjOrThrow(cat: Cat, objId: ObjID): Obj {
const obj = findObj(cat, objId);
if (!obj) throw new Error(`Obj not found: ${objId}`);
return obj;
}
// ─────────────────────────────────────────────────────────────────────────────
// SEC FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findSec(cat: Cat, objId: ObjID, secId: SecID): Sec | undefined {
return findObj(cat, objId)?.secs.find(s => s.id === secId);
}
export function findSecOrThrow(cat: Cat, objId: ObjID, secId: SecID): Sec {
const sec = findSec(cat, objId, secId);
if (!sec) throw new Error(`Sec not found: ${secId} in obj ${objId}`);
return sec;
}
// ─────────────────────────────────────────────────────────────────────────────
// ATT FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findAtt(cat: Cat, objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined {
if (secId) return findSec(cat, objId, secId)?.atts.find(a => a.id === attId);
return findObj(cat, objId)?.atts.find(a => a.id === attId);
}
export function findAttOrThrow(cat: Cat, objId: ObjID, secId: SecID | undefined, attId: AttID): Att {
const att = findAtt(cat, objId, secId, attId);
if (!att) throw new Error(`Att not found: ${attId}`);
return att;
}
// ─────────────────────────────────────────────────────────────────────────────
// VIEW FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findView(cat: Cat, objId: ObjID, secId: SecID, viewId: ViewID): View | undefined {
return findSec(cat, objId, secId)?.visual?.views?.find(v => v.id === viewId);
}
export function findViewOrThrow(cat: Cat, objId: ObjID, secId: SecID, viewId: ViewID): View {
const view = findView(cat, objId, secId, viewId);
if (!view) throw new Error(`View not found: ${viewId}`);
return view;
}
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT FINDERS
// ─────────────────────────────────────────────────────────────────────────────
export function findHotspot(cat: Cat, objId: ObjID, secId: SecID, viewId: ViewID, hotspotId: HotspotID): Hotspot | undefined {
return findView(cat, objId, secId, viewId)?.hotspots?.find(h => h.id === hotspotId);
}
export function findHotspotOrThrow(cat: Cat, objId: ObjID, secId: SecID, viewId: ViewID, hotspotId: HotspotID): Hotspot {
const hs = findHotspot(cat, objId, secId, viewId, hotspotId);
if (!hs) throw new Error(`Hotspot not found: ${hotspotId}`);
return hs;
}

@ -0,0 +1,164 @@
// ─────────────────────────────────────────────────────────────────────────────
// CACHE - vcen/lib/cache.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sistema de caché para resoluciones y config states.
* Proporciona memoization para optimizar el rendimiento del editor.
*/
import type { ViewResolution, ConfigState } from './types';
interface ResolutionCache {
resolution: ViewResolution;
timestamp: number;
}
interface ConfigStateCache {
state: ConfigState;
timestamp: number;
}
const MAX_CACHE_SIZE = 50;
const CACHE_TTL = 30000; // 30 segundos
const _resolutionCache = new Map<string, ResolutionCache>();
const _configStateCache = new Map<string, ConfigStateCache>();
/**
* Genera key para caché de resolución
*/
function resolutionKey(viewId: string, objId: string, catId: string): string {
return `${catId}:${objId}:${viewId}`;
}
/**
* Genera key para caché de config state
*/
function configStateKey(catId: string, objStatesHash: string, secStatesHash: string): string {
return `${catId}:${objStatesHash}:${secStatesHash}`;
}
/**
* Obtiene resolución de caché si es válida
*/
export function getCachedResolution(
viewId: string,
objId: string,
catId: string
): ViewResolution | null {
const key = resolutionKey(viewId, objId, catId);
const cached = _resolutionCache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > CACHE_TTL) {
_resolutionCache.delete(key);
return null;
}
return cached.resolution;
}
/**
* Guarda resolución en caché
*/
export function cacheResolution(
viewId: string,
objId: string,
catId: string,
resolution: ViewResolution
): void {
const key = resolutionKey(viewId, objId, catId);
// LRU eviction si el caché está lleno
if (_resolutionCache.size >= MAX_CACHE_SIZE) {
const oldestKey = _resolutionCache.keys().next().value;
_resolutionCache.delete(oldestKey);
}
_resolutionCache.set(key, { resolution, timestamp: Date.now() });
}
/**
* Obtiene config state de caché si es válido
*/
export function getCachedConfigState(
catId: string,
objStatesHash: string,
secStatesHash: string
): ConfigState | null {
const key = configStateKey(catId, objStatesHash, secStatesHash);
const cached = _configStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > CACHE_TTL) {
_configStateCache.delete(key);
return null;
}
return cached.state;
}
/**
* Guarda config state en caché
*/
export function cacheConfigState(
catId: string,
objStatesHash: string,
secStatesHash: string,
state: ConfigState
): void {
const key = configStateKey(catId, objStatesHash, secStatesHash);
if (_configStateCache.size >= MAX_CACHE_SIZE) {
const oldestKey = _configStateCache.keys().next().value;
_configStateCache.delete(oldestKey);
}
_configStateCache.set(key, { state, timestamp: Date.now() });
}
/**
* Limpia cachés obsoletos
*/
export function clearExpiredCache(): void {
const now = Date.now();
for (const [key, value] of _resolutionCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
_resolutionCache.delete(key);
}
}
for (const [key, value] of _configStateCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
_configStateCache.delete(key);
}
}
}
/**
* Limpia todo el caché
*/
export function clearAllCache(): void {
_resolutionCache.clear();
_configStateCache.clear();
}
/**
* Obtiene estadísticas del caché
*/
export function getCacheStats(): {
resolution: { size: number; keys: string[] };
configState: { size: number; keys: string[] };
} {
return {
resolution: {
size: _resolutionCache.size,
keys: Array.from(_resolutionCache.keys()),
},
configState: {
size: _configStateCache.size,
keys: Array.from(_configStateCache.keys()),
},
};
}

@ -0,0 +1,243 @@
// ─────────────────────────────────────────────────────────────────────────────
// TYPE GUARDS - vcen/lib/guards.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Type guards para validar estructuras de dominio del motor vcen.
* Proporciona seguridad de tipos en runtime para estructuras complejas.
*/
import type {
AttDomain,
AttStringDomain,
AttNumberDomain,
AttCurrencyDomain,
AttBooleanDomain,
AttOptionDomain,
AttDateOnlyDomain,
AttTimeDomain,
AttDatetimeDomain,
ViewConfig,
StaticConfig,
DynamicConfig,
LayersConfig,
ApiConfig,
D3Config,
Visual,
Hotspot
} from './types';
// ─────────────────────────────────────────────────────────────────────────────
// ATTRIBUTE DOMAIN GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar AttDomain
*/
export function isAttDomain(value: unknown): value is AttDomain {
if (typeof value !== 'object' || value === null) return false;
const d = value as Record<string, unknown>;
const kind = d.kind;
return typeof kind === 'string' &&
['string', 'number', 'boolean', 'date', 'datetime', 'time', 'currency', 'option'].includes(kind);
}
/**
* Guard para AttStringDomain
*/
export function isAttStringDomain(value: unknown): value is AttStringDomain {
if (!isAttDomain(value)) return false;
const d = value as AttDomain;
if (d.kind !== 'string') return false;
const strDomain = d as AttStringDomain;
const mode = strDomain.mode;
if (typeof mode !== 'string') return false;
if (mode === 'list') return Array.isArray(strDomain.list) && strDomain.list.every(v => typeof v === 'string');
if (mode === 'pattern') return typeof strDomain.pattern === 'string';
if (mode === 'fixed') return typeof strDomain.fixed === 'string';
if (mode === 'input') return true;
return false;
}
/**
* Guard para AttDomain con modo 'fixed'
*/
export function isAttDomainWithFixedMode(value: unknown): value is AttDomain & { mode: 'fixed'; fixed: string | number | boolean | ISODate | ISOTime | ISODatetime } {
if (!isAttDomain(value)) return false;
const d = value as AttDomain;
if (d.kind === 'string') {
const strDomain = d as AttStringDomain;
return strDomain.mode === 'fixed' && typeof strDomain.fixed === 'string';
}
if (d.kind === 'number') {
const numDomain = d as AttNumberDomain;
return numDomain.mode === 'fixed' && typeof numDomain.fixed === 'number';
}
if (d.kind === 'boolean') {
const boolDomain = d as AttBooleanDomain;
return boolDomain.mode === 'fixed' && typeof boolDomain.fixed === 'boolean';
}
if (d.kind === 'option') {
const optDomain = d as AttOptionDomain;
return optDomain.mode === 'fixed' && typeof optDomain.fixed === 'string';
}
if (d.kind === 'date') {
const dateDomain = d as AttDateOnlyDomain;
return dateDomain.mode === 'fixed' && typeof dateDomain.fixed === 'string';
}
if (d.kind === 'time') {
const timeDomain = d as AttTimeDomain;
return timeDomain.mode === 'fixed' && typeof timeDomain.fixed === 'string';
}
if (d.kind === 'datetime') {
const datetimeDomain = d as AttDatetimeDomain;
return datetimeDomain.mode === 'fixed' && typeof datetimeDomain.fixed === 'string';
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// VIEW CONFIG GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar ViewConfig
*/
export function isViewConfig(value: unknown): value is ViewConfig {
if (typeof value !== 'object' || value === null) return false;
const config = value as Record<string, unknown>;
const strategy = config.strategy;
return typeof strategy === 'string' &&
['static', 'dynamic', 'layers', 'api', 'd3'].includes(strategy);
}
/**
* Guard para ViewConfig con estrategia específica
*/
export function isViewConfigWithStrategy<S extends ViewConfig['strategy']>(
value: unknown,
strategy: S
): value is ViewConfig & { strategy: S } {
if (!isViewConfig(value)) return false;
return value.strategy === strategy;
}
/**
* Guard para StaticConfig
*/
export function isStaticConfig(value: unknown): value is StaticConfig {
return isViewConfigWithStrategy(value, 'static') &&
typeof (value as StaticConfig).url === 'string';
}
/**
* Guard para DynamicConfig
*/
export function isDynamicConfig(value: unknown): value is DynamicConfig {
return isViewConfigWithStrategy(value, 'dynamic') &&
(value as DynamicConfig).template !== undefined;
}
/**
* Guard para LayersConfig
*/
export function isLayersConfig(value: unknown): value is LayersConfig {
return isViewConfigWithStrategy(value, 'layers') &&
Array.isArray((value as LayersConfig).layers);
}
/**
* Guard para ApiConfig
*/
export function isApiConfig(value: unknown): value is ApiConfig {
return isViewConfigWithStrategy(value, 'api') &&
(value as ApiConfig).endpoint !== undefined;
}
/**
* Guard para D3Config
*/
export function isD3Config(value: unknown): value is D3Config {
return isViewConfigWithStrategy(value, 'd3') &&
typeof (value as D3Config).modelUrl === 'string';
}
// ─────────────────────────────────────────────────────────────────────────────
// VISUAL GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Visual
*/
export function isVisual(value: unknown): value is Visual {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
// Validar campos opcionales
if ('basePath' in v && typeof v.basePath !== 'string') return false;
if ('fallback' in v && typeof v.fallback !== 'string') return false;
// Validar arrays opcionales
if ('views' in v && !Array.isArray(v.views)) return false;
if ('defaultViewId' in v && typeof v.defaultViewId !== 'string') return false;
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Hotspot
*/
export function isHotspot(value: unknown): value is Hotspot {
if (typeof value !== 'object' || value === null) return false;
const h = value as Record<string, unknown>;
// Validar campos requeridos
if (typeof h.id !== 'string') return false;
if (typeof h.label !== 'object' || h.label === null) return false;
if (typeof h.mode !== 'string' || !['tooltip', 'popup'].includes(h.mode)) return false;
if (typeof h.posX !== 'number' && typeof h.posX !== 'string') return false;
if (typeof h.posY !== 'number' && typeof h.posY !== 'string') return false;
// Validar campos opcionales
if ('radius' in h && typeof h.radius !== 'number') return false;
if ('color' in h && typeof h.color !== 'string') return false;
if ('zIndex' in h && typeof h.zIndex !== 'number') return false;
return true;
}
/**
* Guard para Hotspot con posición en porcentaje
*/
export function isHotspotWithPercentagePosition(value: unknown): value is Hotspot & { posX: string; posY: string } {
if (!isHotspot(value)) return false;
const posRE = /^\d+%$/;
return posRE.test(String(value.posX)) && posRE.test(String(value.posY));
}
/**
* Guard para Hotspot con posición en píxeles
*/
export function isHotspotWithPixelPosition(value: unknown): value is Hotspot & { posX: number; posY: number } {
if (!isHotspot(value)) return false;
return typeof value.posX === 'number' && typeof value.posY === 'number';
}
// ─────────────────────────────────────────────────────────────────────────────
// UTILITY TYPES FOR JSGL INTEGRATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Tipo para representar fechas ISO
*/
export type ISODate = string; // YYYY-MM-DD
export type ISOTime = string; // HH:MM:SS
export type ISODatetime = string; // YYYY-MM-DDTHH:MM:SS
Loading…
Cancel
Save

Powered by TurnKey Linux.