- 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
parent
f956144577
commit
030bf3a765
@ -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>
|
||||
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,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);
|
||||
}
|
||||
@ -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…
Reference in new issue