{"id":8971,"date":"2026-03-18T13:35:42","date_gmt":"2026-03-18T12:35:42","guid":{"rendered":"https:\/\/kekoph.com\/?page_id=8971"},"modified":"2026-08-24T02:22:57","modified_gmt":"2026-08-24T00:22:57","slug":"portfolio-test","status":"publish","type":"page","link":"https:\/\/kekoph.com\/?page_id=8971","title":{"rendered":"Portfolio TEST"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"8971\" class=\"elementor elementor-8971\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-43af690 e-con-full e-flex qodef-elementor-content-no e-con e-parent\" data-id=\"43af690\" data-element_type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-a04d8c3 elementor-widget elementor-widget-html\" data-id=\"a04d8c3\" data-element_type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<!--\n  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  KEKO PORTFOLIO SPHERE \u2014 Elementor v4\n  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n  INSTRUCCIONES:\n  1. En Elementor, a\u00f1ade un widget \"HTML\" en tu secci\u00f3n\n  2. Aseg\u00farate de que la secci\u00f3n tiene altura m\u00ednima 100vh\n  3. Pega TODO este c\u00f3digo en el widget HTML\n  4. Guarda y previsualiza\n\n  NOVEDADES v4:\n  - Vista final DENTRO de la esfera (BASE_ZOOM) con FOV envolvente\n  - Intro cinematogr\u00e1fica: arranca con el globo entero visible\n    y hace zoom-in hasta la vista \"dentro\" de la esfera\n  - Giro lento autom\u00e1tico al terminar la intro\n  - Cualquier interacci\u00f3n del usuario cancela la intro\n  - Carga de fotos en PARALELO (antes era una a una: mucho m\u00e1s r\u00e1pido)\n  - Bug de paginaci\u00f3n de la API corregido (se repet\u00edan fotos 31-60)\n  - Zoom con rueda: si ya est\u00e1s en el l\u00edmite, la p\u00e1gina vuelve a scrollear\n  - En m\u00f3vil el swipe vertical scrollea la p\u00e1gina (configurable)\n  - Respeta \"prefers-reduced-motion\" y pausa el render fuera de pantalla\n\n  AJUSTES R\u00c1PIDOS: mira el bloque CONFIG del script\n  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-->\n\n<style>\n  @import url('https:\/\/fonts.googleapis.com\/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:ital,wght@0,300;0,400;1,300&display=swap');\n\n  .keko-sphere-wrap {\n    position: relative;\n    width: 100%;\n    height: 100vh;\n    min-height: 600px;\n    background: #0a0a0a;\n    overflow: hidden;\n    font-family: 'DM Sans', sans-serif;\n    cursor: crosshair;\n    \/* pan-y = el navegador gestiona el scroll vertical, nosotros el giro horizontal *\/\n    touch-action: pan-y;\n  }\n  .keko-sphere-wrap.keko-lock-touch { touch-action: none; }\n\n  .keko-sphere-wrap canvas {\n    display: block;\n    position: absolute;\n    inset: 0;\n  }\n\n  .keko-sphere-wrap .keko-vignette {\n    position: absolute;\n    inset: 0;\n    background: radial-gradient(ellipse at center, transparent 45%, rgba(10,10,10,0.5) 85%, rgba(10,10,10,0.9) 100%);\n    pointer-events: none;\n    z-index: 2;\n  }\n\n  .keko-sphere-wrap .keko-title {\n    position: absolute;\n    top: 40px;\n    left: 50%;\n    transform: translateX(-50%);\n    z-index: 10;\n    text-align: center;\n    pointer-events: none;\n    opacity: 0;\n    animation: kekoFadeIn 1.5s ease 0.5s forwards;\n  }\n  .keko-sphere-wrap .keko-title h2 {\n    font-family: 'Syne', sans-serif;\n    font-weight: 800;\n    font-size: clamp(1.2rem, 3vw, 2rem);\n    letter-spacing: 0.35em;\n    text-transform: uppercase;\n    color: #f0ece4;\n    margin: 0;\n    mix-blend-mode: difference;\n  }\n  .keko-sphere-wrap .keko-title p {\n    font-size: 0.75rem;\n    letter-spacing: 0.2em;\n    text-transform: uppercase;\n    color: rgba(240,236,228,0.4);\n    margin-top: 6px;\n  }\n\n  .keko-sphere-wrap .keko-tooltip {\n    position: absolute;\n    z-index: 20;\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity 0.25s ease;\n    transform: translate(-50%, -120%);\n  }\n  .keko-sphere-wrap .keko-tooltip.visible { opacity: 1; }\n  .keko-sphere-wrap .keko-tooltip-inner {\n    background: rgba(240,236,228,0.95);\n    color: #0a0a0a;\n    font-family: 'Syne', sans-serif;\n    font-weight: 700;\n    font-size: 0.8rem;\n    letter-spacing: 0.08em;\n    text-transform: uppercase;\n    padding: 8px 16px;\n    border-radius: 2px;\n    white-space: nowrap;\n  }\n  .keko-sphere-wrap .keko-tooltip-arrow {\n    width: 0; height: 0;\n    border-left: 6px solid transparent;\n    border-right: 6px solid transparent;\n    border-top: 6px solid rgba(240,236,228,0.95);\n    margin: 0 auto;\n  }\n\n  .keko-sphere-wrap .keko-loader {\n    position: absolute;\n    inset: 0;\n    z-index: 100;\n    background: #0a0a0a;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    transition: opacity 0.8s ease;\n  }\n  .keko-sphere-wrap .keko-loader.hidden { opacity: 0; pointer-events: none; }\n  .keko-sphere-wrap .keko-loader-text {\n    font-family: 'Syne', sans-serif;\n    font-weight: 800;\n    font-size: 0.9rem;\n    letter-spacing: 0.3em;\n    text-transform: uppercase;\n    color: #f0ece4;\n  }\n  .keko-sphere-wrap .keko-loader-bar {\n    width: 200px; height: 2px;\n    background: rgba(240,236,228,0.1);\n    margin-top: 20px; border-radius: 1px;\n    overflow: hidden;\n  }\n  .keko-sphere-wrap .keko-loader-fill {\n    height: 100%;\n    background: #f0ece4;\n    width: 0%;\n    transition: width 0.3s ease;\n  }\n\n  .keko-sphere-wrap .keko-hint {\n    position: absolute;\n    bottom: 30px;\n    left: 50%;\n    transform: translateX(-50%);\n    z-index: 10;\n    font-size: 0.65rem;\n    letter-spacing: 0.25em;\n    text-transform: uppercase;\n    color: rgba(240,236,228,0.25);\n    pointer-events: none;\n    opacity: 0;\n  }\n  .keko-sphere-wrap .keko-hint.show { animation: kekoFadeIn 1.5s ease forwards; }\n\n  .keko-sphere-wrap .keko-lightbox {\n    position: absolute;\n    inset: 0;\n    z-index: 50;\n    background: rgba(10,10,10,0.92);\n    backdrop-filter: blur(20px);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-direction: column;\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.4s ease;\n    cursor: pointer;\n  }\n  .keko-sphere-wrap .keko-lightbox.active { opacity: 1; pointer-events: all; }\n  .keko-sphere-wrap .keko-lightbox img {\n    max-width: 85%;\n    max-height: 70%;\n    object-fit: contain;\n    border-radius: 2px;\n    box-shadow: 0 40px 80px rgba(0,0,0,0.6);\n    opacity: 0;\n    transition: opacity 0.4s ease;\n  }\n  .keko-sphere-wrap .keko-lightbox img.loaded { opacity: 1; }\n  .keko-sphere-wrap .keko-lightbox-title {\n    font-family: 'Syne', sans-serif;\n    font-weight: 700;\n    font-size: 1rem;\n    letter-spacing: 0.15em;\n    text-transform: uppercase;\n    margin-top: 20px;\n    color: #f0ece4;\n  }\n  .keko-sphere-wrap .keko-lightbox-link {\n    font-size: 0.7rem;\n    letter-spacing: 0.2em;\n    text-transform: uppercase;\n    color: rgba(240,236,228,0.5);\n    margin-top: 8px;\n    text-decoration: none;\n    pointer-events: all;\n    transition: color 0.3s;\n    border-bottom: 1px solid rgba(240,236,228,0.15);\n    padding-bottom: 2px;\n  }\n  .keko-sphere-wrap .keko-lightbox-link:hover,\n  .keko-sphere-wrap .keko-lightbox-link:focus-visible { color: #f0ece4; border-color: #f0ece4; }\n\n  @keyframes kekoFadeIn {\n    from { opacity: 0; transform: translateX(-50%) translateY(10px); }\n    to { opacity: 1; transform: translateX(-50%) translateY(0); }\n  }\n\n  \/* \u2500\u2500 Mobile responsive \u2500\u2500 *\/\n  @media (max-width: 768px) {\n    .keko-sphere-wrap { min-height: 100svh; }\n    .keko-sphere-wrap .keko-title { top: max(20px, env(safe-area-inset-top, 20px)); }\n    .keko-sphere-wrap .keko-title h2 { letter-spacing: 0.2em; }\n    .keko-sphere-wrap .keko-title p { font-size: 0.6rem; letter-spacing: 0.15em; }\n    .keko-sphere-wrap .keko-hint { bottom: max(20px, env(safe-area-inset-bottom, 20px)); font-size: 0.55rem; letter-spacing: 0.15em; }\n    .keko-sphere-wrap .keko-lightbox {\n      padding: 20px;\n      padding-top: max(20px, env(safe-area-inset-top, 20px));\n      padding-bottom: max(20px, env(safe-area-inset-bottom, 20px));\n    }\n    .keko-sphere-wrap .keko-lightbox img { max-width: 95%; max-height: 60%; }\n    .keko-sphere-wrap .keko-lightbox-title { font-size: 0.8rem; margin-top: 14px; }\n    .keko-sphere-wrap .keko-lightbox-link { font-size: 0.6rem; }\n    .keko-sphere-wrap .keko-tooltip-inner { font-size: 0.7rem; padding: 6px 12px; }\n  }\n  @media (max-width: 400px) {\n    .keko-sphere-wrap { min-height: 500px; }\n    .keko-sphere-wrap .keko-title h2 { font-size: 1rem; }\n  }\n<\/style>\n\n<div class=\"keko-sphere-wrap\" id=\"kekoSphereWrap\">\n  <div class=\"keko-loader\" id=\"kekoLoader\">\n    <div class=\"keko-loader-text\">Cargando portfolio<\/div>\n    <div class=\"keko-loader-bar\"><div class=\"keko-loader-fill\" id=\"kekoLoaderFill\"><\/div><\/div>\n  <\/div>\n  <div class=\"keko-title\">\n    <h2>Portfolio<\/h2>\n    <p>Explora el universo visual<\/p>\n  <\/div>\n  <div class=\"keko-vignette\"><\/div>\n  <div class=\"keko-tooltip\" id=\"kekoTooltip\">\n    <div class=\"keko-tooltip-inner\" id=\"kekoTooltipInner\"><\/div>\n    <div class=\"keko-tooltip-arrow\"><\/div>\n  <\/div>\n  <div class=\"keko-lightbox\" id=\"kekoLightbox\">\n    <img decoding=\"async\" id=\"kekoLbImg\" src=\"\" alt=\"\">\n    <div class=\"keko-lightbox-title\" id=\"kekoLbTitle\"><\/div>\n    <a class=\"keko-lightbox-link\" id=\"kekoLbLink\" href=\"#\" target=\"_blank\" rel=\"noopener\">Ver proyecto \u2192<\/a>\n  <\/div>\n  <div class=\"keko-hint\" id=\"kekoHint\"><\/div>\n<\/div>\n\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/three.js\/r128\/three.min.js\"><\/script>\n<script>\n(function () {\n  'use strict';\n\n  const wrap = document.getElementById('kekoSphereWrap');\n  if (!wrap || wrap.dataset.kekoReady === '1') return;   \/* evita doble init en el editor de Elementor *\/\n  wrap.dataset.kekoReady = '1';\n\n  \/* Espera a que three.js est\u00e9 disponible (por si el CDN tarda) *\/\n  if (typeof THREE === 'undefined') {\n    let tries = 0;\n    const wait = setInterval(function () {\n      if (typeof THREE !== 'undefined') { clearInterval(wait); boot(); }\n      else if (++tries > 100) { clearInterval(wait); console.error('three.js no se ha cargado'); }\n    }, 100);\n  } else {\n    boot();\n  }\n\nfunction boot() {\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 DETECCI\u00d3N DE DISPOSITIVO \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  const isMobile = \/Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini\/i.test(navigator.userAgent) || window.innerWidth < 768;\n  const isLowEnd = isMobile && (navigator.hardwareConcurrency || 4) <= 4;\n  const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 CONFIG \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  const API_BASE      = 'https:\/\/kekoph.com\/?rest_route=\/wp\/v2\/portfolio-item&_embed=1';\n  const TOTAL_PHOTOS  = isLowEnd ? 80 : 130;\n  const TEX_SIZE      = isMobile ? 128 : 256;   \/* potencia de 2 \u2192 mipmaps y mejor nitidez *\/\n  const SPHERE_RADIUS = 10;\n  const NODE_BASE_SIZE = 0.52;\n  const LINE_OPACITY  = 0.03;\n  const LOAD_CONCURRENCY = isMobile ? 6 : 12;   \/* im\u00e1genes simult\u00e1neas *\/\n\n  \/* Tama\u00f1o seg\u00fan profundidad *\/\n  const DEPTH_SCALE_MIN = 0.4;\n  const DEPTH_SCALE_MAX = 1.8;\n\n  \/* Zoom manual *\/\n  const ZOOM_MIN = 3;            \/* 3 = casi en el centro de la esfera (radio 10) *\/\n  const ZOOM_MAX = 32;\n  const ZOOM_SPEED = 0.8;\n  const ZOOM_SMOOTH = 0.08;\n\n  \/* \u2500\u2500 VISTA INTERIOR (lo nuevo de la v4) \u2500\u2500 *\/\n  const BASE_ZOOM        = isMobile ? 6.5 : 6;  \/* posici\u00f3n final: DENTRO de la esfera (< 10 = dentro) *\/\n  const FOV_FAR          = 50;   \/* campo de visi\u00f3n lejos (globo entero) *\/\n  const FOV_INSIDE       = isMobile ? 78 : 72;  \/* campo de visi\u00f3n dentro: cuanto m\u00e1s alto, m\u00e1s envolvente *\/\n  const INSIDE_START     = 15;   \/* zoom al que empieza la transici\u00f3n a \"modo interior\" *\/\n  const INSIDE_END       = BASE_ZOOM;\n  const INSIDE_PHOTO_FRACTION = isMobile ? 0.115 : 0.13; \/* tama\u00f1o de las fotos dentro (fracci\u00f3n del alto de pantalla) *\/\n  const NEAR_FADE        = 3.2;  \/* las fotos que pasan muy cerca se funden en vez de tapar la pantalla *\/\n\n  \/* \u2500\u2500 INTRO (lo nuevo) \u2500\u2500 *\/\n  const INTRO_ENABLED  = true;\n  const INTRO_HOLD     = 900;    \/* ms mostrando el globo entero antes de acercarse *\/\n  const INTRO_DURATION = 5000;   \/* ms que dura el zoom-in  (\u2191 = m\u00e1s lento) *\/\n  const INTRO_FIT_MARGIN = 1.18; \/* aire alrededor del globo en el plano abierto *\/\n  const INTRO_MAX_ZOOM = isMobile ? 42 : 34; \/* tope: en pantallas muy estrechas evita que las fotos queden diminutas *\/\n  const INTRO_SPIN     = 0.0022; \/* giro durante la intro (rad\/frame) *\/\n\n  \/* \u2500\u2500 Giro autom\u00e1tico \u2500\u2500 *\/\n  const IDLE_SPEED     = 0.0009; \/* velocidad del giro lento (\u2191 = m\u00e1s r\u00e1pido) *\/\n  const IDLE_DELAY_MS  = 2500;   \/* espera tras soltar antes de reanudar el giro *\/\n  const IDLE_BLEND     = 0.02;   \/* suavidad de entrada del giro *\/\n  const IDLE_DIR       = { x: 0, y: 1 };  \/* {x:0,y:1} = gira hacia un lado; {x:0,y:-1} = al otro *\/\n\n  \/* \u2500\u2500 Interacci\u00f3n \u2500\u2500 *\/\n  const DRAG_SENS = 0.004;\n  const FRICTION = 0.985;\n  const FRICTION_HOVER = 0.94;\n  const CLICK_THRESHOLD = 6;\n  const MOBILE_ALLOW_PAGE_SCROLL = true; \/* true = el swipe vertical scrollea la p\u00e1gina; false = todo el gesto gira la esfera *\/\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 DOM \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  const loaderEl   = document.getElementById('kekoLoader');\n  const loaderFill = document.getElementById('kekoLoaderFill');\n  const tooltip    = document.getElementById('kekoTooltip');\n  const tooltipInner = document.getElementById('kekoTooltipInner');\n  const lightbox   = document.getElementById('kekoLightbox');\n  const lbImg      = document.getElementById('kekoLbImg');\n  const hintEl     = document.getElementById('kekoHint');\n\n  if (!MOBILE_ALLOW_PAGE_SCROLL) wrap.classList.add('keko-lock-touch');\n\n  hintEl.textContent = isMobile\n    ? 'Arrastra para rotar \u00b7 Pellizca para zoom \u00b7 Toca una foto para ampliar'\n    : 'Arrastra para rotar \u00b7 Scroll para zoom \u00b7 Click en una foto para ampliar';\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 ESTADO \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  let scene, camera, renderer, raycaster, mouse, sphereGroup;\n  let photoSprites = [], portfolioData = [];\n  let hoveredSprite = null, isDragging = false;\n  let previousMouse = { x: 0, y: 0 };\n  let loadedCount = 0;\n\n  let velX = 0, velY = 0, lastDx = 0, lastDy = 0;\n  let idleTime = 0;\n  let lastDragDir = { x: IDLE_DIR.x, y: IDLE_DIR.y };\n  let dragStartX = 0, dragStartY = 0;\n\n  let baseZoom, targetZoom, currentZoom;\n  let introActive = false, introTime = 0, introStartZoom = 0;\n  let lbOpen = false;\n  let isVisible = true;\n  let frameCount = 0;\n\n  const _camWorldPos = new THREE.Vector3();\n  const _spriteWorldPos = new THREE.Vector3();\n  const _normal = new THREE.Vector3();\n\n  let cachedRect = null;\n  function updateRect() { cachedRect = wrap.getBoundingClientRect(); }\n  function getRect() { if (!cachedRect) updateRect(); return cachedRect; }\n  function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); }\n  function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) \/ 2; }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 API (paginada) \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  function parseItem(item) {\n    const media = item._embedded && item._embedded['wp:featuredmedia'] && item._embedded['wp:featuredmedia'][0];\n    const sizes = media && media.media_details && media.media_details.sizes;\n    const thumb = (sizes && ((sizes.medium && sizes.medium.source_url) || (sizes.thumbnail && sizes.thumbnail.source_url))) || (media && media.source_url) || '';\n    const full = (media && media.source_url) || thumb;\n    return { title: item.title ? item.title.rendered : '', link: item.link, thumb: thumb, full: full };\n  }\n\n  async function fetchPortfolio() {\n    try {\n      const allItems = [];\n      const perPage = 100;                                  \/* FIX: per_page constante en todas las p\u00e1ginas *\/\n      const pages = Math.ceil(TOTAL_PHOTOS \/ perPage);\n      for (let page = 1; page <= pages; page++) {\n        const res = await fetch(API_BASE + '&per_page=' + perPage + '&page=' + page);\n        if (!res.ok) break;\n        const data = await res.json();\n        if (!data.length) break;\n        allItems.push.apply(allItems, data);\n        if (data.length < perPage) break;\n      }\n      portfolioData = allItems.slice(0, TOTAL_PHOTOS).map(parseItem).filter(function (d) { return d.thumb; });\n      if (!portfolioData.length) throw new Error('sin resultados');\n      return portfolioData;\n    } catch (e) {\n      console.error('API error', e);\n      portfolioData = [\n        {title:'Barracudas',link:'https:\/\/kekoph.com\/?portfolio-item=barracudas',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2026\/02\/IMG_8131-300x200.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2026\/02\/IMG_8131-scaled.jpg'},\n        {title:'Musiksanne',link:'https:\/\/kekoph.com\/?portfolio-item=musiksanne-4',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2026\/02\/IMG_7975-300x200.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2026\/02\/IMG_7975.jpg'},\n        {title:'The Palace',link:'https:\/\/kekoph.com\/?portfolio-item=the-palace',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/E65F6271-37FF-4F4C-A056-AC68FBF49083-2870-000001B0017E61C9-300x200.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/E65F6271-37FF-4F4C-A056-AC68FBF49083-2870-000001B0017E61C9.jpg'},\n        {title:'Beverly Portraits',link:'https:\/\/kekoph.com\/?portfolio-item=beverly-portraits-4',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9618-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9618.jpg'},\n        {title:'Beverly Portraits',link:'https:\/\/kekoph.com\/?portfolio-item=beverly-portraits-3',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9535-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9535.jpg'},\n        {title:'Beverly Portraits',link:'https:\/\/kekoph.com\/?portfolio-item=beverly-portraits-2',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9442-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9442.jpg'},\n        {title:'Beverly Portraits',link:'https:\/\/kekoph.com\/?portfolio-item=beverly-portraits',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9299-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9299.jpg'},\n        {title:'Maria Portraits',link:'https:\/\/kekoph.com\/?portfolio-item=maria-portraits',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9070-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9070.jpg'},\n        {title:'Musiksanne',link:'https:\/\/kekoph.com\/?portfolio-item=musiksanne-3',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9772-240x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9772.jpg'},\n        {title:'Musiksanne',link:'https:\/\/kekoph.com\/?portfolio-item=musiksanne-2',thumb:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9905-200x300.jpg',full:'https:\/\/kekoph.com\/wp-content\/uploads\/2025\/10\/DSCF9905.jpg'}\n      ];\n      return portfolioData;\n    }\n  }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 GEOMETR\u00cdA \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  function fibonacciSphere(n, radius) {\n    const pts = [], ga = Math.PI * (3 - Math.sqrt(5));\n    for (let i = 0; i < n; i++) {\n      const y = 1 - (i \/ (n - 1)) * 2, r = Math.sqrt(1 - y * y), t = ga * i;\n      pts.push(new THREE.Vector3(Math.cos(t) * r * radius, y * radius, Math.sin(t) * r * radius));\n    }\n    return pts;\n  }\n\n  function buildConnections(pts, maxD) {\n    const e = [], maxD2 = maxD * maxD;   \/* distanceToSquared: evita sqrt en el bucle O(n\u00b2) *\/\n    for (let i = 0; i < pts.length; i++)\n      for (let j = i + 1; j < pts.length; j++)\n        if (pts[i].distanceToSquared(pts[j]) < maxD2) e.push([i, j]);\n    return e;\n  }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 TEXTURAS \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  const maxAniso = 4;\n\n  function finishTexture(canvas) {\n    const tex = new THREE.CanvasTexture(canvas);\n    tex.minFilter = THREE.LinearMipmapLinearFilter;\n    tex.magFilter = THREE.LinearFilter;\n    tex.anisotropy = maxAniso;\n    tex.needsUpdate = true;\n    return tex;\n  }\n\n  function placeholderTexture() {\n    const s = TEX_SIZE, c = document.createElement('canvas');\n    c.width = s; c.height = s;\n    const ctx = c.getContext('2d');\n    ctx.beginPath(); ctx.arc(s \/ 2, s \/ 2, s \/ 2 - 2, 0, Math.PI * 2);\n    ctx.fillStyle = '#2a2a2a'; ctx.fill();\n    return finishTexture(c);\n  }\n\n  function loadTexture(url) {\n    return new Promise(function (resolve) {\n      const img = new Image();\n      img.crossOrigin = 'anonymous';\n      img.decoding = 'async';\n      img.onload = function () {\n        const s = TEX_SIZE, c = document.createElement('canvas');\n        c.width = s; c.height = s;\n        const ctx = c.getContext('2d');\n        ctx.beginPath(); ctx.arc(s \/ 2, s \/ 2, s \/ 2, 0, Math.PI * 2); ctx.clip();\n        const a = img.width \/ img.height; let sw, sh, sx, sy;\n        if (a > 1) { sh = img.height; sw = sh; sx = (img.width - sw) \/ 2; sy = 0; }\n        else { sw = img.width; sh = sw; sx = 0; sy = (img.height - sh) \/ 2; }\n        ctx.drawImage(img, sx, sy, sw, sh, 0, 0, s, s);\n        ctx.beginPath(); ctx.arc(s \/ 2, s \/ 2, s \/ 2 - 1, 0, Math.PI * 2);\n        ctx.strokeStyle = 'rgba(240,236,228,0.3)'; ctx.lineWidth = 2; ctx.stroke();\n        resolve(finishTexture(c));\n      };\n      img.onerror = function () { resolve(placeholderTexture()); };\n      img.src = url;\n    });\n  }\n\n  \/* Carga en paralelo con l\u00edmite de concurrencia (antes: una a una) *\/\n  function loadTexturesParallel(urls, onProgress) {\n    const out = new Array(urls.length);\n    let next = 0;\n    function worker() {\n      if (next >= urls.length) return Promise.resolve();\n      const i = next++;\n      return loadTexture(urls[i]).then(function (tex) {\n        out[i] = tex;\n        onProgress();\n        return worker();\n      });\n    }\n    const workers = [];\n    for (let k = 0; k < Math.min(LOAD_CONCURRENCY, urls.length); k++) workers.push(worker());\n    return Promise.all(workers).then(function () { return out; });\n  }\n\n  function createDotTex(filled) {\n    const s = 64, c = document.createElement('canvas');\n    c.width = s; c.height = s;\n    const ctx = c.getContext('2d');\n    ctx.beginPath(); ctx.arc(s \/ 2, s \/ 2, filled ? s \/ 4 : s \/ 6, 0, Math.PI * 2);\n    if (filled) { ctx.fillStyle = 'rgba(240,236,228,0.15)'; ctx.fill(); }\n    ctx.strokeStyle = 'rgba(240,236,228,0.12)'; ctx.lineWidth = 1.5; ctx.stroke();\n    return finishTexture(c);\n  }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 ESCENA \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  function initScene() {\n    const w = wrap.clientWidth, h = Math.max(wrap.clientHeight, 1);\n    scene = new THREE.Scene();\n    camera = new THREE.PerspectiveCamera(FOV_FAR, w \/ h, 0.1, 200);\n    baseZoom = BASE_ZOOM;\n    currentZoom = targetZoom = baseZoom;\n    camera.fov = fovForZoom(currentZoom);\n    camera.position.z = currentZoom;\n    camera.updateProjectionMatrix();\n\n    renderer = new THREE.WebGLRenderer({ antialias: !isLowEnd, alpha: false, powerPreference: 'high-performance' });\n    renderer.setSize(w, h);\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));\n    renderer.setClearColor(0x0a0a0a, 1);\n    wrap.insertBefore(renderer.domElement, wrap.firstChild);\n\n    raycaster = new THREE.Raycaster();\n    mouse = new THREE.Vector2(-999, -999);\n    sphereGroup = new THREE.Group();\n    scene.add(sphereGroup);\n    \/* Nota: las luces se han eliminado \u2014 SpriteMaterial no reacciona a la iluminaci\u00f3n *\/\n  }\n\n  \/* El campo de visi\u00f3n se abre a medida que la c\u00e1mara entra en la esfera (efecto envolvente) *\/\n  function fovForZoom(z) {\n    const t = Math.min(Math.max((INSIDE_START - z) \/ (INSIDE_START - INSIDE_END), 0), 1);\n    return FOV_FAR + (FOV_INSIDE - FOV_FAR) * t;\n  }\n\n  \/* Distancia de c\u00e1mara a la que el globo entero entra en pantalla *\/\n  function fitZoom(margin) {\n    const vFov = FOV_FAR * Math.PI \/ 180;\n    const hFov = 2 * Math.atan(Math.tan(vFov \/ 2) * camera.aspect);\n    const fov = Math.min(vFov, hFov);\n    return Math.min((SPHERE_RADIUS * (margin || 1.15)) \/ Math.sin(fov \/ 2), 80);\n  }\n\n  async function buildSphere(data) {\n    const n = data.length, total = Math.max(Math.floor(n * 1.25), 40);\n    const pts = fibonacciSphere(total, SPHERE_RADIUS);\n\n    \/* Mapa \u00edndice-nodo \u2192 \u00edndice-foto (antes: indexOf dentro del bucle = O(n\u00b2)) *\/\n    const photoAt = new Map();\n    const step = total \/ n;\n    for (let i = 0; i < n; i++) photoAt.set(Math.floor(i * step), i);\n\n    \/* L\u00edneas *\/\n    const edges = buildConnections(pts, SPHERE_RADIUS * 0.38);\n    const lp = new Float32Array(edges.length * 6);\n    for (let i = 0; i < edges.length; i++) {\n      const a = pts[edges[i][0]], b = pts[edges[i][1]], o = i * 6;\n      lp[o] = a.x; lp[o + 1] = a.y; lp[o + 2] = a.z;\n      lp[o + 3] = b.x; lp[o + 4] = b.y; lp[o + 5] = b.z;\n    }\n    const lg = new THREE.BufferGeometry();\n    lg.setAttribute('position', new THREE.BufferAttribute(lp, 3));\n    const lineMesh = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: 0xf0ece4, transparent: true, opacity: LINE_OPACITY, depthWrite: false }));\n    lineMesh.renderOrder = 0;\n    sphereGroup.add(lineMesh);\n\n    \/* Texturas de fotos en paralelo *\/\n    const urls = data.map(function (d) { return d.thumb; });\n    const textures = await loadTexturesParallel(urls, function () {\n      loadedCount++;\n      loaderFill.style.width = (loadedCount \/ urls.length * 100) + '%';\n    });\n\n    \/* Puntos decorativos: 2 materiales compartidos en vez de uno por sprite *\/\n    const dotMatA = new THREE.SpriteMaterial({ map: createDotTex(false), transparent: true, depthWrite: false });\n    const dotMatB = new THREE.SpriteMaterial({ map: createDotTex(true), transparent: true, depthWrite: false });\n\n    for (let i = 0; i < total; i++) {\n      const pi = photoAt.has(i) ? photoAt.get(i) : -1;\n      const isP = pi !== -1;\n      let mat;\n      if (isP) {\n        mat = new THREE.SpriteMaterial({ map: textures[pi], transparent: true, depthTest: false, depthWrite: false });\n      } else {\n        mat = Math.random() > 0.6 ? dotMatB : dotMatA;\n      }\n      const sp = new THREE.Sprite(mat);\n      sp.position.copy(pts[i]);\n      sp.renderOrder = isP ? 2 : 1;\n      const sz = isP ? NODE_BASE_SIZE : 0.08 + Math.random() * 0.06;\n      sp.scale.set(sz, sz, 1);\n      sp.userData = { isPhoto: isP, baseSize: sz, index: pi, originalPos: pts[i].clone() };\n      sphereGroup.add(sp);\n      if (isP) photoSprites.push(sp);\n    }\n  }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 INTRO \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  function startIntro() {\n    if (!INTRO_ENABLED || reduceMotion) {\n      currentZoom = targetZoom = baseZoom;\n      velY = IDLE_DIR.y * IDLE_SPEED;\n      velX = IDLE_DIR.x * IDLE_SPEED;\n      idleTime = IDLE_DELAY_MS;\n      hintEl.classList.add('show');\n      return;\n    }\n    introActive = true;\n    introTime = 0;\n    introStartZoom = Math.min(fitZoom(INTRO_FIT_MARGIN), INTRO_MAX_ZOOM);\n    currentZoom = targetZoom = introStartZoom;\n    camera.position.z = currentZoom;\n    camera.fov = fovForZoom(currentZoom);\n    camera.updateProjectionMatrix();\n  }\n\n  function endIntro(userCancelled) {\n    if (!introActive) return;\n    introActive = false;\n    targetZoom = userCancelled ? clampZoom(currentZoom) : baseZoom;\n    if (userCancelled) {\n      velX = 0; velY = 0; idleTime = 0;\n    } else {\n      velX = IDLE_DIR.x * IDLE_SPEED;\n      velY = IDLE_DIR.y * IDLE_SPEED;\n      idleTime = IDLE_DELAY_MS;   \/* el giro lento contin\u00faa sin pausa *\/\n    }\n    hintEl.classList.add('show');\n  }\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 EVENTOS \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  function setMouseFromClient(cx, cy) {\n    const r = getRect();\n    mouse.x = ((cx - r.left) \/ r.width) * 2 - 1;\n    mouse.y = -((cy - r.top) \/ r.height) * 2 + 1;\n  }\n\n  wrap.addEventListener('mousemove', function (e) {\n    if (lbOpen) return;\n    setMouseFromClient(e.clientX, e.clientY);\n    if (isDragging) {\n      lastDx = (e.clientX - previousMouse.x) * DRAG_SENS;\n      lastDy = (e.clientY - previousMouse.y) * DRAG_SENS;\n      sphereGroup.rotation.y += lastDx;\n      sphereGroup.rotation.x += lastDy;\n      idleTime = 0;\n    }\n    previousMouse.x = e.clientX; previousMouse.y = e.clientY;\n  });\n\n  wrap.addEventListener('mouseleave', function () {\n    mouse.set(-999, -999);\n    hoveredSprite = null;\n    tooltip.classList.remove('visible');\n  });\n\n  wrap.addEventListener('mousedown', function (e) {\n    if (lbOpen) return;\n    endIntro(true);\n    isDragging = true;\n    lastDx = 0; lastDy = 0; velX = 0; velY = 0;\n    dragStartX = e.clientX; dragStartY = e.clientY;\n    previousMouse.x = e.clientX; previousMouse.y = e.clientY;\n    wrap.style.cursor = 'grabbing';\n  });\n\n  \/* En window: si sueltas fuera del canvas, el arrastre termina igualmente *\/\n  window.addEventListener('mouseup', function () {\n    if (isDragging) {\n      velX = lastDy; velY = lastDx;\n      const mag = Math.hypot(velX, velY);\n      if (mag > 0.0001) { lastDragDir.x = velX \/ mag; lastDragDir.y = velY \/ mag; }\n      lastDx = 0; lastDy = 0;\n    }\n    isDragging = false;\n    wrap.style.cursor = 'crosshair';\n  });\n\n  wrap.addEventListener('click', function (e) {\n    if (lbOpen) return;\n    if (Math.hypot(e.clientX - dragStartX, e.clientY - dragStartY) > CLICK_THRESHOLD) return;\n    if (hoveredSprite && hoveredSprite.userData.isPhoto) openLb(portfolioData[hoveredSprite.userData.index]);\n  });\n\n  \/* Zoom con rueda \u2014 si ya est\u00e1s en el tope, deja scrollear la p\u00e1gina *\/\n  wrap.addEventListener('wheel', function (e) {\n    if (lbOpen) return;\n    const next = clampZoom(targetZoom + (e.deltaY > 0 ? ZOOM_SPEED : -ZOOM_SPEED));\n    if (next === targetZoom && !introActive) return;\n    e.preventDefault();\n    endIntro(true);\n    targetZoom = clampZoom(next);\n  }, { passive: false });\n\n  \/* \u2500\u2500 Touch \u2500\u2500 *\/\n  let touchStart = { x: 0, y: 0 };\n  let lastPinchDist = 0;\n  let isTouchZooming = false;\n  let gestureAxis = null;   \/* null | 'rotate' | 'scroll' *\/\n\n  function getPinchDist(t) { return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); }\n\n  wrap.addEventListener('touchstart', function (e) {\n    if (lbOpen) return;\n    if (e.touches.length === 2) {\n      endIntro(true);\n      isTouchZooming = true; isDragging = false; gestureAxis = 'rotate';\n      lastPinchDist = getPinchDist(e.touches);\n    } else if (e.touches.length === 1) {\n      isDragging = true; isTouchZooming = false;\n      gestureAxis = MOBILE_ALLOW_PAGE_SCROLL ? null : 'rotate';\n      lastDx = 0; lastDy = 0; velX = 0; velY = 0;\n      touchStart.x = e.touches[0].clientX; touchStart.y = e.touches[0].clientY;\n      previousMouse.x = touchStart.x; previousMouse.y = touchStart.y;\n    }\n  }, { passive: true });\n\n  wrap.addEventListener('touchmove', function (e) {\n    if (lbOpen) return;\n\n    if (e.touches.length === 2 && isTouchZooming) {\n      if (e.cancelable) e.preventDefault();\n      const dist = getPinchDist(e.touches);\n      targetZoom = clampZoom(targetZoom + (lastPinchDist - dist) * 0.04);\n      lastPinchDist = dist;\n      return;\n    }\n\n    if (e.touches.length !== 1 || !isDragging || isTouchZooming) return;\n\n    const tx = e.touches[0].clientX, ty = e.touches[0].clientY;\n\n    \/* Decide el eje del gesto en el primer movimiento significativo *\/\n    if (gestureAxis === null) {\n      const dx = Math.abs(tx - touchStart.x), dy = Math.abs(ty - touchStart.y);\n      if (Math.max(dx, dy) < 8) return;\n      gestureAxis = dy > dx * 1.4 ? 'scroll' : 'rotate';\n      if (gestureAxis === 'scroll') { isDragging = false; return; }  \/* deja scrollear la p\u00e1gina *\/\n      endIntro(true);\n    }\n\n    if (e.cancelable) e.preventDefault();\n    endIntro(true);\n    lastDx = (tx - previousMouse.x) * DRAG_SENS;\n    lastDy = (ty - previousMouse.y) * DRAG_SENS;\n    sphereGroup.rotation.y += lastDx;\n    sphereGroup.rotation.x += lastDy;\n    idleTime = 0;\n    previousMouse.x = tx; previousMouse.y = ty;\n    setMouseFromClient(tx, ty);\n  }, { passive: false });\n\n  wrap.addEventListener('touchend', function (e) {\n    if (e.touches.length < 2) isTouchZooming = false;\n    if (e.touches.length !== 0) return;\n\n    if (isDragging) {\n      velX = lastDy; velY = lastDx;\n      const mag = Math.hypot(velX, velY);\n      if (mag > 0.0001) { lastDragDir.x = velX \/ mag; lastDragDir.y = velY \/ mag; }\n      lastDx = 0; lastDy = 0;\n    }\n    isDragging = false;\n\n    const moved = Math.hypot(previousMouse.x - touchStart.x, previousMouse.y - touchStart.y);\n    if (!lbOpen && gestureAxis !== 'scroll' && moved < CLICK_THRESHOLD) {\n      setMouseFromClient(touchStart.x, touchStart.y);\n      raycaster.setFromCamera(mouse, camera);\n      const hits = raycaster.intersectObjects(photoSprites);\n      if (hits.length && hits[0].object.userData.isPhoto) {\n        endIntro(true);\n        openLb(portfolioData[hits[0].object.userData.index]);\n      }\n    }\n    gestureAxis = null;\n  }, { passive: true });\n\n  \/* \u2500\u2500 Lightbox \u2500\u2500 *\/\n  function openLb(item) {\n    if (!item) return;\n    lbOpen = true;\n    lbImg.classList.remove('loaded');\n    lbImg.onload = function () { lbImg.classList.add('loaded'); };\n    lbImg.src = item.full;\n    lbImg.alt = item.title || 'Foto del portfolio';\n    document.getElementById('kekoLbTitle').textContent = item.title || '';\n    document.getElementById('kekoLbLink').href = item.link || '#';\n    lightbox.classList.add('active');\n    hoveredSprite = null;\n    tooltip.classList.remove('visible');\n  }\n  function closeLb() {\n    lbOpen = false;\n    lightbox.classList.remove('active');\n  }\n  lightbox.addEventListener('click', function (e) { if (e.target.tagName !== 'A') closeLb(); });\n  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeLb(); });\n\n  \/* \u2500\u2500 Pausa el render si la secci\u00f3n no est\u00e1 en pantalla o la pesta\u00f1a est\u00e1 oculta \u2500\u2500 *\/\n  if ('IntersectionObserver' in window) {\n    new IntersectionObserver(function (entries) { isVisible = entries[0].isIntersecting; }, { threshold: 0 }).observe(wrap);\n  }\n  document.addEventListener('visibilitychange', function () { if (!document.hidden) lastT = performance.now(); });\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 LOOP \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  let lastT = 0;\n\n  function animate(now) {\n    requestAnimationFrame(animate);\n    const delta = Math.min(now - lastT, 100);\n    lastT = now;\n    if (!isVisible || document.hidden) return;\n    const dtf = Math.min(delta \/ 16.667, 3);   \/* independiente de 60\/120 Hz *\/\n\n    if (introActive) {\n      \/* \u2500\u2500 Intro: mantiene el globo entero y luego hace zoom-in \u2500\u2500 *\/\n      introTime += delta;\n      const p = (introTime - INTRO_HOLD) \/ INTRO_DURATION;\n      if (p <= 0) {\n        currentZoom = introStartZoom;\n      } else if (p < 1) {\n        currentZoom = introStartZoom + (baseZoom - introStartZoom) * easeInOutCubic(p);\n      } else {\n        currentZoom = baseZoom;\n        endIntro(false);\n      }\n      targetZoom = currentZoom;\n      const prog = Math.max(0, Math.min(p, 1));\n      const spin = INTRO_SPIN + (IDLE_SPEED - INTRO_SPIN) * prog;\n      sphereGroup.rotation.y += IDLE_DIR.y * spin * dtf;\n      sphereGroup.rotation.x += IDLE_DIR.x * spin * dtf;\n    } else if (!isDragging) {\n      \/* \u2500\u2500 Inercia + giro autom\u00e1tico \u2500\u2500 *\/\n      const f = Math.pow(hoveredSprite ? FRICTION_HOVER : FRICTION, dtf);\n      velX *= f; velY *= f;\n      if (Math.abs(velX) < 0.000005) velX = 0;\n      if (Math.abs(velY) < 0.000005) velY = 0;\n      sphereGroup.rotation.x += velX * dtf;\n      sphereGroup.rotation.y += velY * dtf;\n\n      idleTime += delta;\n      if (idleTime > IDLE_DELAY_MS) {\n        const blend = Math.min((idleTime - IDLE_DELAY_MS) \/ 4000, 1) * IDLE_BLEND * dtf;\n        velX += (lastDragDir.x * IDLE_SPEED - velX) * blend;\n        velY += (lastDragDir.y * IDLE_SPEED - velY) * blend;\n      }\n    }\n\n    \/* Zoom suave *\/\n    currentZoom += (targetZoom - currentZoom) * Math.min(ZOOM_SMOOTH * dtf, 1);\n    camera.position.z = currentZoom;\n\n    \/* FOV din\u00e1mico: se abre al entrar en la esfera *\/\n    const fov = fovForZoom(currentZoom);\n    if (Math.abs(camera.fov - fov) > 0.02) { camera.fov = fov; camera.updateProjectionMatrix(); }\n\n    \/* Hover: raycast cada 2 frames (la posici\u00f3n del tooltip se refresca cada frame) *\/\n    frameCount++;\n    if (!introActive && !lbOpen && (frameCount & 1) === 0) {\n      raycaster.setFromCamera(mouse, camera);\n      const hits = raycaster.intersectObjects(photoSprites);\n      const hit = (hits.length && hits[0].object.userData.isPhoto) ? hits[0].object : null;\n      if (hit !== hoveredSprite) {\n        hoveredSprite = hit;\n        if (!hit) {\n          tooltip.classList.remove('visible');\n          if (!isDragging) wrap.style.cursor = 'crosshair';\n        } else {\n          tooltipInner.textContent = portfolioData[hit.userData.index].title;\n          tooltip.classList.add('visible');\n          if (!isDragging) wrap.style.cursor = 'pointer';\n        }\n      }\n    }\n    if (hoveredSprite) {\n      _spriteWorldPos.copy(hoveredSprite.position).applyMatrix4(sphereGroup.matrixWorld).project(camera);\n      const r = getRect();\n      tooltip.style.left = (_spriteWorldPos.x * 0.5 + 0.5) * r.width + 'px';\n      tooltip.style.top = (-_spriteWorldPos.y * 0.5 + 0.5) * r.height + 'px';\n    }\n\n    \/* Tama\u00f1o por profundidad + respiraci\u00f3n (sin asignaciones nuevas) *\/\n    const t = now * 0.001;\n    camera.getWorldPosition(_camWorldPos);\n    sphereGroup.updateMatrixWorld();\n\n    \/* Rango de profundidad correcto tanto fuera como DENTRO de la esfera:\n       la distancia m\u00ednima a la superficie es |c - R|, no c - R  *\/\n    const minDist = Math.max(Math.abs(currentZoom - SPHERE_RADIUS), 1);\n    const maxDist = currentZoom + SPHERE_RADIUS;\n    const distRange = Math.max(maxDist - minDist, 0.001);\n\n    \/* Cu\u00e1nto estamos en \"modo interior\" (0 = fuera, 1 = dentro del todo) *\/\n    const insideT = Math.min(Math.max((INSIDE_START - currentZoom) \/ (INSIDE_START - INSIDE_END), 0), 1);\n    \/* Dentro, todas las fotos visibles est\u00e1n en la pared a distancia parecida:\n       se anclan a una fracci\u00f3n del alto de pantalla para que no queden diminutas *\/\n    const tanHalf = Math.tan(camera.fov * Math.PI \/ 360);\n    const wallDist = currentZoom < SPHERE_RADIUS ? (SPHERE_RADIUS + currentZoom) : Math.max(currentZoom - SPHERE_RADIUS, 1);\n    const anchorSize = INSIDE_PHOTO_FRACTION * 2 * wallDist * tanHalf;\n\n    for (let i = 0; i < photoSprites.length; i++) {\n      const s = photoSprites[i];\n      const o = Math.sin(t + i * 0.8) * 0.02;\n      _normal.copy(s.userData.originalPos).normalize();\n      s.position.copy(s.userData.originalPos).addScaledVector(_normal, o);\n      _spriteWorldPos.copy(s.position).applyMatrix4(sphereGroup.matrixWorld);\n      const dist = _camWorldPos.distanceTo(_spriteWorldPos);\n      const depthT = 1 - Math.min(Math.max((dist - minDist) \/ distRange, 0), 1);\n\n      const outSize = s.userData.baseSize * (DEPTH_SCALE_MIN + (DEPTH_SCALE_MAX - DEPTH_SCALE_MIN) * depthT);\n      const inSize = anchorSize * (0.75 + 0.5 * depthT);\n      let sz = outSize + (inSize - outSize) * insideT;\n      if (s === hoveredSprite) sz *= 1.4;\n      s.scale.set(sz, sz, 1);\n\n      \/* Las fotos que pasan pegadas a la c\u00e1mara se funden en vez de tapar la pantalla *\/\n      const nearFade = Math.min(Math.max((dist - 1.0) \/ NEAR_FADE, 0), 1);\n      const opOut = 0.5 + depthT * 0.5;\n      const opIn = 0.78 + depthT * 0.22;\n      s.material.opacity = (opOut + (opIn - opOut) * insideT) * nearFade;\n    }\n\n    renderer.render(scene, camera);\n  }\n\n  \/* \u2500\u2500 Resize (con debounce) \u2500\u2500 *\/\n  let resizeTimer = null;\n  window.addEventListener('resize', function () {\n    clearTimeout(resizeTimer);\n    resizeTimer = setTimeout(function () {\n      updateRect();\n      const w = wrap.clientWidth, h = Math.max(wrap.clientHeight, 1);\n      camera.aspect = w \/ h;\n      camera.updateProjectionMatrix();\n      renderer.setSize(w, h);\n      if (introActive) introStartZoom = Math.min(fitZoom(INTRO_FIT_MARGIN), INTRO_MAX_ZOOM);\n    }, 150);\n  });\n  window.addEventListener('scroll', updateRect, { passive: true });\n\n  \/* \u2500\u2500 Arranque \u2500\u2500 *\/\n  (async function () {\n    initScene();\n    updateRect();\n    const data = await fetchPortfolio();\n    await buildSphere(data);\n    loaderEl.classList.add('hidden');\n    startIntro();\n    lastT = performance.now();\n    requestAnimationFrame(animate);\n  })();\n\n}\n})();\n<\/script>\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-f4a58e0 e-con-full e-flex qodef-elementor-content-no e-con e-child\" data-id=\"f4a58e0\" data-element_type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-d6a4d68 elementor-widget elementor-widget-html\" data-id=\"d6a4d68\" data-element_type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n     KEKOPH \u00b7 PORTFOLIO PLANETARIO\n     Pega TODO esto dentro del widget \"HTML\" de Elementor.\n     El contenedor padre debe ir sin padding y a ancho completo.\n     Ajusta la configuraci\u00f3n en el bloque CFG (m\u00e1s abajo).\n     \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n\n<div id=\"kk-sky\" class=\"kk-sky\">\n  <canvas class=\"kk-links\" aria-hidden=\"true\"><\/canvas>\n  <div class=\"kk-stage\" role=\"list\"><\/div>\n  <div class=\"kk-vignette\" aria-hidden=\"true\"><\/div>\n\n  <header class=\"kk-head\">\n    <h2 class=\"kk-title\">PORTFOLIO<\/h2>\n    <p class=\"kk-sub\">EXPLORA EL UNIVERSO VISUAL<\/p>\n  <\/header>\n\n  <p class=\"kk-hint\">Arrastra para mirar alrededor \u00b7 Rueda o pellizca para acercar<\/p>\n\n  <div class=\"kk-status\" aria-live=\"polite\">\n    <div class=\"kk-bar\"><i><\/i><\/div>\n    <span class=\"kk-status-txt\">Cargando el cielo\u2026<\/span>\n  <\/div>\n\n  <div class=\"kk-lb\" hidden>\n    <button class=\"kk-lb-close\" type=\"button\" aria-label=\"Cerrar\">\u2715<\/button>\n    <figure class=\"kk-lb-fig\">\n      <img class=\"kk-lb-img\" alt=\"\">\n      <figcaption>\n        <span class=\"kk-lb-title\"><\/span>\n        <a class=\"kk-lb-link\" href=\"#\">Ver proyecto \u2192<\/a>\n      <\/figcaption>\n    <\/figure>\n  <\/div>\n<\/div>\n\n<style>\n\/* ---------- Escenario ---------- *\/\n#kk-sky{\n  --kk-bg:#07070b;\n  --kk-line:rgba(190,205,255,.30);\n  --kk-ink:#f2f2f4;\n  --kk-dim:rgba(242,242,244,.42);\n  position:relative;\n  width:100%;\n  height:100svh;\n  min-height:560px;\n  overflow:hidden;\n  background:\n    radial-gradient(120% 90% at 50% 8%, #12131c 0%, var(--kk-bg) 62%),\n    var(--kk-bg);\n  cursor:grab;\n  touch-action:none;\n  user-select:none;\n  -webkit-user-select:none;\n  contain:layout paint style;\n}\n#kk-sky.is-drag{cursor:grabbing}\n\n#kk-sky .kk-links{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}\n#kk-sky .kk-stage{position:absolute;inset:0;transform:translateZ(0)}\n#kk-sky .kk-vignette{\n  position:absolute;inset:0;pointer-events:none;\n  background:radial-gradient(78% 70% at 50% 50%, transparent 55%, rgba(7,7,11,.55) 100%);\n}\n\n\/* ---------- Fotos-estrella ---------- *\/\n#kk-sky .kk-star{\n  position:absolute;left:0;top:0;\n  transform-origin:50% 50%;\n  will-change:transform,opacity;\n  border:0;padding:0;margin:0;\n  background:#15151d;\n  border-radius:var(--kk-corner,2px);\n  overflow:hidden;\n  cursor:pointer;\n  display:none;\n  outline:none;\n  backface-visibility:hidden;\n}\n#kk-sky .kk-star.is-on{display:block}\n#kk-sky .kk-star img{\n  width:100%;height:100%;object-fit:cover;display:block;\n  opacity:0;transition:opacity .55s ease;\n}\n#kk-sky .kk-star img.is-ready{opacity:1}\n#kk-sky .kk-star::after{\n  content:\"\";position:absolute;inset:0;border-radius:inherit;\n  box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.26), 0 0 0 0 rgba(255,255,255,0);\n  transition:box-shadow .25s ease;\n}\n#kk-sky .kk-star:is(:hover,:focus-visible)::after{\n  box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.9), 0 0 30px 2px rgba(160,180,255,.3);\n}\n\n#kk-sky .kk-tip{\n  position:absolute;left:0;top:0;z-index:99999;\n  transform:translate(-50%,-50%);\n  padding:5px 10px;\n  background:#f5f5f7;color:#0b0b10;\n  font:600 11px\/1.25 system-ui,-apple-system,\"Segoe UI\",sans-serif;\n  letter-spacing:.06em;text-transform:uppercase;\n  border-radius:4px;white-space:nowrap;max-width:240px;\n  overflow:hidden;text-overflow:ellipsis;\n  pointer-events:none;opacity:0;transition:opacity .18s ease;\n}\n#kk-sky .kk-tip.is-on{opacity:1}\n\n\/* ---------- Cabecera y ayudas ---------- *\/\n#kk-sky .kk-head{\n  position:absolute;left:0;right:0;top:clamp(24px,6vh,64px);\n  text-align:center;pointer-events:none;z-index:5;\n}\n#kk-sky .kk-title{\n  margin:0;color:var(--kk-ink);\n  font:800 clamp(28px,5.2vw,64px)\/1 inherit;\n  letter-spacing:.34em;text-indent:.34em;\n}\n#kk-sky .kk-sub{\n  margin:.9em 0 0;color:var(--kk-dim);\n  font:400 clamp(9px,1.05vw,13px)\/1 inherit;\n  letter-spacing:.42em;text-indent:.42em;\n}\n#kk-sky .kk-hint{\n  position:absolute;left:50%;bottom:22px;transform:translateX(-50%);\n  margin:0;z-index:5;pointer-events:none;\n  color:var(--kk-dim);\n  font:400 11px\/1 system-ui,-apple-system,\"Segoe UI\",sans-serif;\n  letter-spacing:.16em;text-transform:uppercase;\n  transition:opacity .6s ease;\n}\n#kk-sky .kk-hint.is-off{opacity:0}\n\n#kk-sky .kk-status{\n  position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);\n  z-index:6;text-align:center;transition:opacity .5s ease;\n}\n#kk-sky .kk-status.is-off{opacity:0;pointer-events:none}\n#kk-sky .kk-bar{width:180px;height:1px;background:rgba(255,255,255,.14);overflow:hidden;margin:0 auto 14px}\n#kk-sky .kk-bar i{display:block;height:100%;width:0;background:var(--kk-ink);transition:width .3s ease}\n#kk-sky .kk-status-txt{\n  color:var(--kk-dim);\n  font:400 11px\/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif;\n  letter-spacing:.2em;text-transform:uppercase;\n}\n\n\/* ---------- Lightbox ---------- *\/\n#kk-sky .kk-lb{\n  position:absolute;inset:0;z-index:100;\n  display:flex;align-items:center;justify-content:center;\n  padding:clamp(16px,4vw,56px);\n  background:rgba(5,5,9,.93);\n  backdrop-filter:blur(6px);\n  animation:kk-fade .28s ease;\n}\n#kk-sky .kk-lb[hidden]{display:none}\n@keyframes kk-fade{from{opacity:0}to{opacity:1}}\n#kk-sky .kk-lb-fig{margin:0;display:flex;flex-direction:column;gap:16px;max-height:100%}\n#kk-sky .kk-lb-img{\n  max-width:min(1100px,100%);max-height:74svh;\n  object-fit:contain;display:block;margin:auto;\n}\n#kk-sky .kk-lb-fig figcaption{\n  display:flex;gap:20px;align-items:baseline;justify-content:space-between;\n  flex-wrap:wrap;color:var(--kk-ink);\n}\n#kk-sky .kk-lb-title{font:600 14px\/1.4 inherit;letter-spacing:.12em;text-transform:uppercase}\n#kk-sky .kk-lb-link{\n  color:var(--kk-ink);text-decoration:none;\n  font:400 11px\/1 system-ui,-apple-system,\"Segoe UI\",sans-serif;\n  letter-spacing:.18em;text-transform:uppercase;\n  border-bottom:1px solid rgba(255,255,255,.3);padding-bottom:4px;\n}\n#kk-sky .kk-lb-link:hover{border-bottom-color:var(--kk-ink)}\n#kk-sky .kk-lb-close{\n  position:absolute;top:16px;right:20px;\n  width:40px;height:40px;border:0;border-radius:50%;\n  background:rgba(255,255,255,.08);color:var(--kk-ink);\n  font-size:15px;cursor:pointer;line-height:1;\n}\n#kk-sky .kk-lb-close:hover{background:rgba(255,255,255,.18)}\n\n@media (max-width:768px){\n  #kk-sky .kk-hint{font-size:9.5px;bottom:16px}\n}\n@media (prefers-reduced-motion:reduce){\n  #kk-sky .kk-star img{transition:none}\n}\n<\/style>\n\n<script>\n(function () {\n  'use strict';\n\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 CONFIGURACI\u00d3N \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  const CFG = {\n    \/\/ Origen de datos\n    API: 'https:\/\/kekoph.com\/?rest_route=\/wp\/v2\/portfolio-item',\n\n    \/\/ N\u00ba de fotos en escena y campo de visi\u00f3n, por tama\u00f1o de pantalla.\n    \/\/ Menos fotos = cada una m\u00e1s grande. El empaquetado decide el tama\u00f1o solo.\n    TIERS: [\n      { max:  700, items:  90, fov: 78 },   \/\/ m\u00f3vil\n      { max: 1100, items: 100, fov: 72 },   \/\/ tablet\n      { max: 1600, items: 110, fov: 64 },   \/\/ port\u00e1til\n      { max: 1e9,  items: 140, fov: 62 }    \/\/ escritorio\n    ],\n\n    GAP: 0.985,            \/\/ 1 = fotos roz\u00e1ndose \u00b7 0.94 = m\u00e1s aire entre ellas\n    CORNER: 2,             \/\/ px de redondeo de esquina\n    AR_MIN: 0.62,          \/\/ proporci\u00f3n m\u00ednima (vertical)\n    AR_MAX: 1.62,          \/\/ proporci\u00f3n m\u00e1xima (apaisada)\n    SIZE_MIN: 0.88,        \/\/ variaci\u00f3n de tama\u00f1o entre fotos (m\u00edn)\n    SIZE_MAX: 1.14,        \/\/ variaci\u00f3n de tama\u00f1o entre fotos (m\u00e1x)\n    DEPTH_JITTER: 0.16,    \/\/ variaci\u00f3n de profundidad (capas)\n\n    \/\/ Geometr\u00eda de la c\u00fapula\n    LAT_MIN: -58, LAT_MAX: 88,      \/\/ franja de cielo ocupada, en grados\n    PITCH_MIN: -20, PITCH_MAX: 52,  \/\/ hasta d\u00f3nde puede mirar el usuario\n\n    \/\/ Empaquetado (se ejecuta una vez al cargar, ~40 ms con 140 fotos)\n    RELAX_ROUNDS: 6, RELAX_ITERS: 40,\n\n    \/\/ C\u00e1mara\n    FOV_MIN: 40, FOV_MAX: 84,\n    DRIFT: 0.010,          \/\/ rad\/s de deriva autom\u00e1tica (0 = quieto)\n    DAMPING: 0.90,         \/\/ inercia tras soltar\n\n    \/\/ Ambiente\n    STARS: 420,            \/\/ puntos de fondo que rellenan el negro\n    NEIGHBORS: 3,          \/\/ vecinos enlazados por foto\n    MAX_LINES: 320,\n\n    \/\/ Carga de im\u00e1genes\n    IMG_TARGET: 300,       \/\/ px pedidos a WP (sube a 500 para nitidez retina)\n    CONCURRENT: 4\n  };\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 FIN CONFIGURACI\u00d3N \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n  const root = document.getElementById('kk-sky');\n  if (!root || root.dataset.kkInit) return;\n  root.dataset.kkInit = '1';\n\n  const stage   = root.querySelector('.kk-stage');\n  const canvas  = root.querySelector('.kk-links');\n  const ctx     = canvas.getContext('2d');\n  const statusEl= root.querySelector('.kk-status');\n  const statusTx= root.querySelector('.kk-status-txt');\n  const bar     = root.querySelector('.kk-bar i');\n  const hint    = root.querySelector('.kk-hint');\n  const lb      = root.querySelector('.kk-lb');\n  const lbImg   = root.querySelector('.kk-lb-img');\n  const lbTitle = root.querySelector('.kk-lb-title');\n  const lbLink  = root.querySelector('.kk-lb-link');\n\n  const RAD = Math.PI \/ 180;\n  const clamp = (v, a, b) => v < a ? a : v > b ? b : v;\n  const AREA_K = Math.sqrt(Math.PI \/ 4);  \/\/ iguala el \u00e1rea de una foto sea cual sea su proporci\u00f3n\n\n  let W = 0, H = 0, DPR = 1, F = 1, F_REF = 1, TILE = 160, FOV = 62, PACK_A = 0.1;\n  let yaw = 0, pitch = 0.18;\n  let vYaw = 0, vPitch = 0;\n  let dragging = false, moved = false, lastX = 0, lastY = 0, lastT = 0;\n  let items = [], pairs = [], stars = [], ready = false;\n  let tip = null, hovered = null;\n  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n  function tier() {\n    const w = root.getBoundingClientRect().width || window.innerWidth;\n    for (const t of CFG.TIERS) if (w < t.max) return t;\n    return CFG.TIERS[CFG.TIERS.length - 1];\n  }\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 1. Datos \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  function bestSize(media, target) {\n    const sizes = media && media.media_details && media.media_details.sizes;\n    if (!sizes) return media && media.source_url;\n    const list = Object.keys(sizes).map(k => sizes[k])\n      .filter(s => s && s.source_url && s.width)\n      .sort((a, b) => a.width - b.width);\n    if (!list.length) return media.source_url;\n    for (const s of list) if (s.width >= target) return s.source_url;\n    return list[list.length - 1].source_url;\n  }\n\n  async function fetchPage(page, slim) {\n    const q = slim\n      ? '&_embed=wp:featuredmedia&_fields=id,link,title,_links,_embedded'\n      : '&_embed=1';\n    const res = await fetch(`${CFG.API}&per_page=100&page=${page}${q}`, { credentials: 'omit' });\n    if (!res.ok) throw new Error('HTTP ' + res.status);\n    return {\n      data: await res.json(),\n      pages: parseInt(res.headers.get('X-WP-TotalPages') || '1', 10) || 1\n    };\n  }\n\n  async function loadItems() {\n    let slim = true, first;\n    try {\n      first = await fetchPage(1, true);\n      if (first.data.length && !first.data[0]._embedded) { slim = false; first = await fetchPage(1, false); }\n    } catch (e) { first = await fetchPage(1, false); slim = false; }\n\n    let raw = first.data;\n    const pages = Math.min(first.pages, 4);\n    if (pages > 1) {\n      const rest = await Promise.all(\n        Array.from({ length: pages - 1 }, (_, i) => fetchPage(i + 2, slim))\n      );\n      rest.forEach(r => { raw = raw.concat(r.data); });\n    }\n\n    const out = [];\n    for (const post of raw) {\n      const media = post._embedded && post._embedded['wp:featuredmedia'] && post._embedded['wp:featuredmedia'][0];\n      const src = media && bestSize(media, CFG.IMG_TARGET);\n      if (!src) continue;\n      const md = media.media_details;\n      const ar = (md && md.width && md.height)\n        ? clamp(md.width \/ md.height, CFG.AR_MIN, CFG.AR_MAX) : 1.5;\n      out.push({\n        src, ar,\n        mid: bestSize(media, 700),\n        full: bestSize(media, 1400),\n        title: decodeHTML((post.title && post.title.rendered) || ''),\n        link: post.link || '#'\n      });\n    }\n    return out;\n  }\n\n  function decodeHTML(s) {\n    const d = document.createElement('textarea');\n    d.innerHTML = s;\n    return d.value.trim();\n  }\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 2. C\u00fapula y empaquetado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  let seed = 20260824;\n  const rnd = () => (seed = (seed * 1664525 + 1013904223) % 4294967296) \/ 4294967296;\n\n  \/\/ Menor separaci\u00f3n relativa entre dos fotos cualesquiera.\n  \/\/ Es la escala m\u00e1xima a la que se pueden dibujar sin tocarse.\n  function maxScale(list) {\n    let m = Infinity;\n    for (let i = 0; i < list.length; i++) {\n      const a = list[i];\n      for (let j = i + 1; j < list.length; j++) {\n        const b = list[j];\n        const ang = Math.acos(clamp(a.ux * b.ux + a.uy * b.uy + a.uz * b.uz, -1, 1));\n        const v = ang \/ (a.c + b.c);\n        if (v < m) m = v;\n      }\n    }\n    return m;\n  }\n\n  \/\/ Relajaci\u00f3n: las fotos que se pisan se empujan entre s\u00ed sobre la esfera,\n  \/\/ ponderando por su tama\u00f1o, hasta que ninguna invade a otra.\n  function relax(list) {\n    const n = list.length;\n    const sinMin = Math.sin(CFG.LAT_MIN * RAD), sinMax = Math.sin(CFG.LAT_MAX * RAD);\n    let sumC2 = 0;\n    for (const a of list) sumC2 += a.c * a.c;\n    let A = Math.sqrt(4 * ((sinMax - sinMin) \/ 2) * 0.80 \/ sumC2);\n\n    const dx = new Float64Array(n), dy = new Float64Array(n), dz = new Float64Array(n);\n    for (let r = 0; r < CFG.RELAX_ROUNDS; r++) {\n      for (let t = 0; t < CFG.RELAX_ITERS; t++) {\n        dx.fill(0); dy.fill(0); dz.fill(0);\n        for (let i = 0; i < n; i++) {\n          const a = list[i];\n          for (let j = i + 1; j < n; j++) {\n            const b = list[j];\n            const dot = clamp(a.ux * b.ux + a.uy * b.uy + a.uz * b.uz, -1, 1);\n            const ang = Math.acos(dot), need = A * (a.c + b.c);\n            if (ang >= need) continue;\n            const ov = (need - ang);\n            let ex = a.ux - b.ux, ey = a.uy - b.uy, ez = a.uz - b.uz;\n            const L = Math.hypot(ex, ey, ez) || 1e-6;\n            ex \/= L; ey \/= L; ez \/= L;\n            const wi = b.c \/ (a.c + b.c), wj = a.c \/ (a.c + b.c);\n            dx[i] += ex * ov * wi; dy[i] += ey * ov * wi; dz[i] += ez * ov * wi;\n            dx[j] -= ex * ov * wj; dy[j] -= ey * ov * wj; dz[j] -= ez * ov * wj;\n          }\n        }\n        for (let i = 0; i < n; i++) {\n          const a = list[i];\n          let x = a.ux + dx[i] * 0.55, y = a.uy + dy[i] * 0.55, z = a.uz + dz[i] * 0.55;\n          const L = Math.hypot(x, y, z) || 1;\n          x \/= L; y \/= L; z \/= L;\n          if (y < sinMin || y > sinMax) {          \/\/ mantener dentro de la franja\n            const ny = clamp(y, sinMin, sinMax);\n            const h = Math.sqrt(Math.max(1e-9, 1 - ny * ny)), hh = Math.hypot(x, z) || 1e-9;\n            x = x \/ hh * h; z = z \/ hh * h; y = ny;\n          }\n          a.ux = x; a.uy = y; a.uz = z;\n        }\n      }\n      A = maxScale(list) * 1.04;\n    }\n    return maxScale(list) * CFG.GAP;\n  }\n\n  function buildSky(list) {\n    const shuffled = list.slice();\n    for (let i = shuffled.length - 1; i > 0; i--) {\n      const j = Math.floor(rnd() * (i + 1));\n      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];\n    }\n\n    const n = Math.min(shuffled.length, tier().items);\n    const sinMin = Math.sin(CFG.LAT_MIN * RAD), sinMax = Math.sin(CFG.LAT_MAX * RAD);\n    const GOLDEN = Math.PI * (3 - Math.sqrt(5));\n\n    items = [];\n    for (let i = 0; i < n; i++) {\n      const sy = sinMin + (sinMax - sinMin) * ((i + 0.5) \/ n);\n      const r = Math.sqrt(Math.max(0, 1 - sy * sy));\n      const th = i * GOLDEN;\n      const k = CFG.SIZE_MIN + rnd() * (CFG.SIZE_MAX - CFG.SIZE_MIN);\n      const Rrel = 1 + (rnd() - 0.5) * 2 * CFG.DEPTH_JITTER;\n      const d = shuffled[i];\n      const bwR = k * AREA_K * Math.sqrt(d.ar);\n      const bhR = k * AREA_K \/ Math.sqrt(d.ar);\n      items.push({\n        ux: Math.cos(th) * r, uy: sy, uz: Math.sin(th) * r,\n        bwR, bhR, Rrel,\n        c: 0.5 * Math.hypot(bwR, bhR) \/ Rrel,   \/\/ radio angular relativo (circunscrito)\n        bw: 0, bh: 0,\n        src: d.src, mid: d.mid, full: d.full, title: d.title, link: d.link,\n        node: null, img: null, queued: false, loaded: false, hi: false,\n        sx: 0, sy2: 0, vis: false, alpha: 0\n      });\n    }\n\n    PACK_A = relax(items);\n\n    \/\/ Enlaces: 3 vecinas m\u00e1s pr\u00f3ximas sobre la esfera\n    const seen = new Set();\n    pairs = [];\n    for (let i = 0; i < items.length; i++) {\n      const a = items[i], best = [];\n      for (let j = 0; j < items.length; j++) {\n        if (i === j) continue;\n        const b = items[j];\n        const dot = a.ux * b.ux + a.uy * b.uy + a.uz * b.uz;\n        if (best.length < CFG.NEIGHBORS) { best.push({ j, dot }); best.sort((p, q) => q.dot - p.dot); }\n        else if (dot > best[best.length - 1].dot) { best[best.length - 1] = { j, dot }; best.sort((p, q) => q.dot - p.dot); }\n      }\n      for (const b of best) {\n        const key = i < b.j ? i + ':' + b.j : b.j + ':' + i;\n        if (seen.has(key)) continue;\n        seen.add(key);\n        pairs.push([i, b.j]);\n      }\n    }\n    if (pairs.length > CFG.MAX_LINES) pairs.length = CFG.MAX_LINES;\n\n    \/\/ Estrellas de fondo: rellenan el negro sin coste apreciable\n    stars = [];\n    for (let i = 0; i < CFG.STARS; i++) {\n      const sy = -0.98 + rnd() * 1.96;\n      const r = Math.sqrt(Math.max(0, 1 - sy * sy)), th = rnd() * Math.PI * 2;\n      stars.push({\n        ux: Math.cos(th) * r, uy: sy, uz: Math.sin(th) * r,\n        s: 0.5 + rnd() * 1.3,\n        a: 0.10 + rnd() * 0.40\n      });\n    }\n  }\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 3. Nodos y carga diferida \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  const queue = [];\n  let active = 0;\n\n  function makeNode(it) {\n    const el = document.createElement('button');\n    el.type = 'button';\n    el.className = 'kk-star';\n    el.style.width = it.bw.toFixed(1) + 'px';\n    el.style.height = it.bh.toFixed(1) + 'px';\n    el.setAttribute('role', 'listitem');\n    el.setAttribute('aria-label', it.title || 'Ver foto');\n    const img = document.createElement('img');\n    img.alt = it.title || '';\n    img.decoding = 'async';\n    el.appendChild(img);\n    el.addEventListener('click', () => { if (!moved) openLightbox(it); });\n    el.addEventListener('pointerenter', () => showTip(it));\n    el.addEventListener('pointerleave', () => hideTip(it));\n    el.addEventListener('focus', () => showTip(it));\n    el.addEventListener('blur', () => hideTip(it));\n    stage.appendChild(el);\n    it.node = el; it.img = img;\n    return el;\n  }\n\n  function enqueue(it) {\n    if (it.queued) return;\n    it.queued = true;\n    queue.push(it);\n    pump();\n  }\n\n  function pump() {\n    while (active < CFG.CONCURRENT && queue.length) {\n      const it = queue.shift();\n      active++;\n      const done = () => { active--; pump(); };\n      it.img.addEventListener('load', () => { it.loaded = true; it.img.classList.add('is-ready'); done(); }, { once: true });\n      it.img.addEventListener('error', done, { once: true });\n      it.img.src = it.src;\n    }\n  }\n\n  \/\/ Sube de resoluci\u00f3n solo las fotos que se ven realmente grandes\n  let upActive = 0;\n  function upgrade(it) {\n    if (it.hi || !it.loaded || !it.mid || it.mid === it.src || upActive >= 2) return;\n    it.hi = true; upActive++;\n    const pre = new Image();\n    pre.decoding = 'async';\n    pre.onload = () => { it.img.src = it.mid; upActive--; };\n    pre.onerror = () => { upActive--; };\n    pre.src = it.mid;\n  }\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 4. Proyecci\u00f3n y render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  function layout() {\n    for (let i = 0; i < items.length; i++) {\n      const it = items[i];\n      it.bw = TILE * it.bwR;\n      it.bh = TILE * it.bhR;\n      if (it.node) {\n        it.node.style.width = it.bw.toFixed(1) + 'px';\n        it.node.style.height = it.bh.toFixed(1) + 'px';\n      }\n    }\n  }\n\n  function resize() {\n    const r = root.getBoundingClientRect();\n    W = Math.max(1, r.width);\n    H = Math.max(1, r.height);\n    DPR = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = Math.round(W * DPR);\n    canvas.height = Math.round(H * DPR);\n    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);\n\n    \/\/ Proyecci\u00f3n azimutal equidistante: los p\u00edxeles desde el centro de la\n    \/\/ pantalla son proporcionales al \u00e1ngulo, as\u00ed la densidad es uniforme\n    \/\/ de borde a borde y el empaquetado angular se conserva en pantalla.\n    F_REF = (H \/ 2) \/ (FOV * RAD \/ 2);\n    TILE = PACK_A * F_REF;\n    root.style.setProperty('--kk-corner', CFG.CORNER + 'px');\n    layout();\n  }\n\n  let last = performance.now();\n\n  function frame(now) {\n    const dt = Math.min((now - last) \/ 1000, 0.05);\n    last = now;\n    if (!dragging) {\n      yaw += vYaw; pitch += vPitch;\n      const k = Math.pow(CFG.DAMPING, dt * 60);\n      vYaw *= k; vPitch *= k;\n      if (Math.abs(vYaw) < 1e-5) vYaw = 0;\n      if (Math.abs(vPitch) < 1e-5) vPitch = 0;\n      if (!reduceMotion && !vYaw && !vPitch) yaw += CFG.DRIFT * dt;\n    }\n    pitch = clamp(pitch, CFG.PITCH_MIN * RAD, CFG.PITCH_MAX * RAD);\n    if (ready) draw();\n    requestAnimationFrame(frame);\n  }\n\n  function draw() {\n    F = (H \/ 2) \/ (FOV * RAD \/ 2);\n    const cy = Math.cos(yaw), sy = Math.sin(yaw);\n    const cp = Math.cos(pitch), sp = Math.sin(pitch);\n    const cx = W \/ 2, cyS = H \/ 2;\n    const diag = Math.hypot(W, H) \/ 2;\n    const zoom = F \/ F_REF;\n\n    ctx.clearRect(0, 0, W, H);\n\n    \/\/ Estrellas de fondo\n    for (let i = 0; i < stars.length; i++) {\n      const s = stars[i];\n      const x1 = cy * s.ux - sy * s.uz, z1 = sy * s.ux + cy * s.uz, y1 = s.uy;\n      const y2 = cp * y1 + sp * z1, z2 = -sp * y1 + cp * z1;\n      if (z2 >= -0.05) continue;\n      const th = Math.acos(clamp(-z2, -1, 1));\n      const hz = Math.hypot(x1, y2);\n      if (hz < 1e-6) continue;\n      const m = (F * th) \/ hz;\n      const px = cx + x1 * m, py = cyS - y2 * m;\n      if (px < 0 || px > W || py < 0 || py > H) continue;\n      const fade = clamp(1.15 - Math.hypot(px - cx, py - cyS) \/ diag, 0, 1);\n      ctx.fillStyle = 'rgba(214,224,255,' + (s.a * fade).toFixed(3) + ')';\n      ctx.fillRect(px, py, s.s, s.s);\n    }\n\n    \/\/ Fotos\n    for (let i = 0; i < items.length; i++) {\n      const it = items[i];\n      const x1 = cy * it.ux - sy * it.uz, z1 = sy * it.ux + cy * it.uz, y1 = it.uy;\n      const y2 = cp * y1 + sp * z1, z2 = -sp * y1 + cp * z1;\n\n      if (z2 >= -0.02) { park(it); continue; }        \/\/ detr\u00e1s del observador\n\n      const th = Math.acos(clamp(-z2, -1, 1));\n      const hz = Math.hypot(x1, y2);\n      const sc = zoom \/ it.Rrel;                      \/\/ tama\u00f1o angular constante\n      const halfW = it.bw * sc \/ 2, halfH = it.bh * sc \/ 2;\n\n      let px, py;\n      if (hz < 1e-6) { px = cx; py = cyS; }\n      else { const m = (F * th) \/ hz; px = cx + x1 * m; py = cyS - y2 * m; }\n\n      if (px < -halfW || px > W + halfW || py < -halfH || py > H + halfH) { park(it); continue; }\n\n      const depthA = clamp(1.42 - it.Rrel, 0.46, 1);\n      const edgeA  = clamp(1.16 - Math.hypot(px - cx, py - cyS) \/ diag, 0.16, 1);\n      let alpha = depthA * edgeA;\n      if (hovered === it) alpha = 1;\n\n      it.sx = px; it.sy2 = py; it.vis = true; it.alpha = alpha;\n\n      if (!it.node) makeNode(it);\n      if (!it.queued) enqueue(it);\n      if (!dragging && Math.max(halfW, halfH) * 2 > CFG.IMG_TARGET * 1.15) upgrade(it);\n\n      const node = it.node;\n      node.style.transform =\n        'translate3d(' + (px - it.bw \/ 2).toFixed(1) + 'px,' +\n                         (py - it.bh \/ 2).toFixed(1) + 'px,0) scale(' + sc.toFixed(4) + ')';\n      node.style.opacity = alpha.toFixed(3);\n      node.style.zIndex = Math.round(2000 - it.Rrel * 500);\n      if (!node.classList.contains('is-on')) node.classList.add('is-on');\n    }\n\n    \/\/ Malla de enlaces\n    ctx.lineWidth = 1;\n    for (let p = 0; p < pairs.length; p++) {\n      const a = items[pairs[p][0]], b = items[pairs[p][1]];\n      if (!a || !b || !a.vis || !b.vis) continue;\n      const seg = Math.hypot(a.sx - b.sx, a.sy2 - b.sy2);\n      if (seg > diag * 1.1) continue;\n      const fade = clamp(1 - seg \/ (diag * 1.1), 0, 1);\n      const g = ctx.createLinearGradient(a.sx, a.sy2, b.sx, b.sy2);\n      g.addColorStop(0, 'rgba(190,205,255,' + (a.alpha * 0.26 * fade).toFixed(3) + ')');\n      g.addColorStop(1, 'rgba(190,205,255,' + (b.alpha * 0.26 * fade).toFixed(3) + ')');\n      ctx.strokeStyle = g;\n      ctx.beginPath();\n      ctx.moveTo(a.sx, a.sy2);\n      ctx.lineTo(b.sx, b.sy2);\n      ctx.stroke();\n    }\n\n    if (tip && hovered && hovered.vis) {\n      tip.style.transform = 'translate(' + hovered.sx + 'px,' + hovered.sy2 + 'px) translate(-50%,-50%)';\n    }\n  }\n\n  function park(it) {\n    it.vis = false;\n    if (it.node && it.node.classList.contains('is-on')) it.node.classList.remove('is-on');\n  }\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 5. Interacci\u00f3n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  const pointers = new Map();\n  let pinchDist = 0;\n\n  root.addEventListener('pointerdown', e => {\n    if (e.target.closest('.kk-lb')) return;\n    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n    if (pointers.size === 1) {\n      dragging = true; moved = false;\n      lastX = e.clientX; lastY = e.clientY; lastT = performance.now();\n      vYaw = vPitch = 0;\n      root.classList.add('is-drag');\n      root.setPointerCapture(e.pointerId);\n      hint.classList.add('is-off');\n    } else if (pointers.size === 2) {\n      const p = [...pointers.values()];\n      pinchDist = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);\n    }\n  });\n\n  root.addEventListener('pointermove', e => {\n    if (!pointers.has(e.pointerId)) return;\n    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n\n    if (pointers.size === 2) {\n      const p = [...pointers.values()];\n      const dist = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);\n      if (pinchDist) FOV = clamp(FOV * (pinchDist \/ dist), CFG.FOV_MIN, CFG.FOV_MAX);\n      pinchDist = dist;\n      return;\n    }\n    if (!dragging) return;\n\n    const dx = e.clientX - lastX, dy = e.clientY - lastY;\n    if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;\n    yaw   -= dx \/ F;                 \/\/ seguimiento 1:1 con el p\u00edxel\n    pitch += dy \/ F;\n    const dt = Math.max(performance.now() - lastT, 8);\n    vYaw   = -dx \/ F * (16 \/ dt);\n    vPitch =  dy \/ F * (16 \/ dt);\n    lastX = e.clientX; lastY = e.clientY; lastT = performance.now();\n  });\n\n  function release(e) {\n    pointers.delete(e.pointerId);\n    if (pointers.size < 2) pinchDist = 0;\n    try { root.releasePointerCapture(e.pointerId); } catch (_) {}\n    if (pointers.size === 0) {\n      dragging = false;\n      root.classList.remove('is-drag');\n      setTimeout(() => { moved = false; }, 0);\n    }\n  }\n  root.addEventListener('pointerup', release);\n  root.addEventListener('pointercancel', release);\n\n  root.addEventListener('wheel', e => {\n    e.preventDefault();\n    FOV = clamp(FOV + e.deltaY * 0.04, CFG.FOV_MIN, CFG.FOV_MAX);\n  }, { passive: false });\n\n  root.tabIndex = 0;\n  root.addEventListener('keydown', e => {\n    const step = 0.09;\n    if (e.key === 'ArrowLeft')  { yaw -= step; e.preventDefault(); }\n    if (e.key === 'ArrowRight') { yaw += step; e.preventDefault(); }\n    if (e.key === 'ArrowUp')    { pitch += step; e.preventDefault(); }\n    if (e.key === 'ArrowDown')  { pitch -= step; e.preventDefault(); }\n    if (e.key === 'Escape')     closeLightbox();\n  });\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 6. T\u00edtulo y lightbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  function showTip(it) {\n    if (!it.title) return;\n    hovered = it;\n    if (!tip) { tip = document.createElement('div'); tip.className = 'kk-tip'; root.appendChild(tip); }\n    tip.textContent = it.title;\n    tip.classList.add('is-on');\n  }\n  function hideTip(it) {\n    if (hovered === it) hovered = null;\n    if (tip) tip.classList.remove('is-on');\n  }\n\n  function openLightbox(it) {\n    lbImg.src = it.full;\n    lbImg.alt = it.title || '';\n    lbTitle.textContent = it.title || '';\n    lbLink.href = it.link;\n    lb.hidden = false;\n  }\n  function closeLightbox() { lb.hidden = true; lbImg.src = ''; }\n  lb.addEventListener('click', e => {\n    if (e.target === lb || e.target.closest('.kk-lb-close')) closeLightbox();\n  });\n\n  \/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 7. Arranque \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n\n  FOV = tier().fov;\n  new ResizeObserver(resize).observe(root);\n  resize();\n  requestAnimationFrame(frame);\n\n  loadItems()\n    .then(list => {\n      if (!list.length) throw new Error('Sin fotos con imagen destacada');\n      statusTx.textContent = 'Colocando las fotos\u2026';\n      return new Promise(r => setTimeout(() => r(list), 30));   \/\/ deja pintar el mensaje\n    })\n    .then(list => {\n      buildSky(list);\n      resize();\n      ready = true;\n      statusTx.textContent = items.length + ' fotos en \u00f3rbita';\n      bar.style.width = '100%';\n      setTimeout(() => statusEl.classList.add('is-off'), 700);\n      setTimeout(() => hint.classList.add('is-off'), 9000);\n    })\n    .catch(err => {\n      bar.parentElement.style.display = 'none';\n      statusTx.textContent = 'No se pudieron cargar las fotos. Revisa que la ruta REST responda.';\n      console.error('[kekoph-planetario]', err);\n    });\n\n  const ticker = setInterval(() => {\n    if (statusEl.classList.contains('is-off')) { clearInterval(ticker); return; }\n    if (!ready) return;\n    const done = items.filter(i => i.loaded).length;\n    bar.style.width = Math.round(done \/ Math.max(1, items.length) * 100) + '%';\n  }, 300);\n})();\n<\/script>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Cargando portfolio Portfolio Explora el universo visual Ver proyecto \u2192 PORTFOLIO EXPLORA EL UNIVERSO VISUAL Arrastra para mirar alrededor \u00b7 Rueda o pellizca para acercar Cargando el cielo\u2026 \u2715 Ver proyecto \u2192<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_canvas","meta":{"footnotes":""},"class_list":["post-8971","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8971","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/kekoph.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=8971"}],"version-history":[{"count":112,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8971\/revisions"}],"predecessor-version":[{"id":9435,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8971\/revisions\/9435"}],"wp:attachment":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=8971"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}