{"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-03-20T01:00:52","modified_gmt":"2026-03-20T00:00:52","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<!--\r\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\r\n  KEKO PORTFOLIO SPHERE \u2014 Versi\u00f3n Elementor v2\r\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\r\n  \r\n  INSTRUCCIONES:\r\n  1. En Elementor, a\u00f1ade un widget \"HTML\" en tu secci\u00f3n\r\n  2. Aseg\u00farate de que la secci\u00f3n tiene altura m\u00ednima 100vh\r\n  3. Pega TODO este c\u00f3digo en el widget HTML\r\n  4. Guarda y previsualiza\r\n  \r\n  CAMBIOS v2:\r\n  - 130 fotos (paginadas, 100+30)\r\n  - Zoom con rueda del rat\u00f3n y pinch en m\u00f3vil\r\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\r\n-->\r\n\r\n<style>\r\n  .keko-sphere-wrap {\r\n    position: relative;\r\n    width: 100%;\r\n    height: 100vh;\r\n    min-height: 600px;\r\n    background: #0a0a0a;\r\n    overflow: hidden;\r\n    font-family: 'DM Sans', sans-serif;\r\n    cursor: crosshair;\r\n    touch-action: none; \/* prevent browser default gestures *\/\r\n  }\r\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');\r\n\r\n  .keko-sphere-wrap canvas { display: block; }\r\n\r\n  .keko-sphere-wrap .keko-vignette {\r\n    position: absolute;\r\n    inset: 0;\r\n    background: radial-gradient(ellipse at center, transparent 30%, rgba(10,10,10,0.7) 80%, rgba(10,10,10,0.95) 100%);\r\n    pointer-events: none;\r\n    z-index: 2;\r\n  }\r\n\r\n  .keko-sphere-wrap .keko-title {\r\n    position: absolute;\r\n    top: 40px;\r\n    left: 50%;\r\n    transform: translateX(-50%);\r\n    z-index: 10;\r\n    text-align: center;\r\n    pointer-events: none;\r\n    opacity: 0;\r\n    animation: kekoFadeIn 1.5s ease 0.5s forwards;\r\n  }\r\n  .keko-sphere-wrap .keko-title h2 {\r\n    font-family: 'Syne', sans-serif;\r\n    font-weight: 800;\r\n    font-size: clamp(1.2rem, 3vw, 2rem);\r\n    letter-spacing: 0.35em;\r\n    text-transform: uppercase;\r\n    color: #f0ece4;\r\n    margin: 0;\r\n    mix-blend-mode: difference;\r\n  }\r\n  .keko-sphere-wrap .keko-title p {\r\n    font-size: 0.75rem;\r\n    letter-spacing: 0.2em;\r\n    text-transform: uppercase;\r\n    color: rgba(240,236,228,0.4);\r\n    margin-top: 6px;\r\n  }\r\n\r\n  .keko-sphere-wrap .keko-tooltip {\r\n    position: absolute;\r\n    z-index: 20;\r\n    pointer-events: none;\r\n    opacity: 0;\r\n    transition: opacity 0.25s ease;\r\n    transform: translate(-50%, -120%);\r\n  }\r\n  .keko-sphere-wrap .keko-tooltip.visible { opacity: 1; }\r\n  .keko-sphere-wrap .keko-tooltip-inner {\r\n    background: rgba(240,236,228,0.95);\r\n    color: #0a0a0a;\r\n    font-family: 'Syne', sans-serif;\r\n    font-weight: 700;\r\n    font-size: 0.8rem;\r\n    letter-spacing: 0.08em;\r\n    text-transform: uppercase;\r\n    padding: 8px 16px;\r\n    border-radius: 2px;\r\n    white-space: nowrap;\r\n  }\r\n  .keko-sphere-wrap .keko-tooltip-arrow {\r\n    width: 0; height: 0;\r\n    border-left: 6px solid transparent;\r\n    border-right: 6px solid transparent;\r\n    border-top: 6px solid rgba(240,236,228,0.95);\r\n    margin: 0 auto;\r\n  }\r\n\r\n  .keko-sphere-wrap .keko-loader {\r\n    position: absolute;\r\n    inset: 0;\r\n    z-index: 100;\r\n    background: #0a0a0a;\r\n    display: flex;\r\n    flex-direction: column;\r\n    align-items: center;\r\n    justify-content: center;\r\n    transition: opacity 0.8s ease;\r\n  }\r\n  .keko-sphere-wrap .keko-loader.hidden { opacity: 0; pointer-events: none; }\r\n  .keko-sphere-wrap .keko-loader-text {\r\n    font-family: 'Syne', sans-serif;\r\n    font-weight: 800;\r\n    font-size: 0.9rem;\r\n    letter-spacing: 0.3em;\r\n    text-transform: uppercase;\r\n    color: #f0ece4;\r\n  }\r\n  .keko-sphere-wrap .keko-loader-bar {\r\n    width: 200px; height: 2px;\r\n    background: rgba(240,236,228,0.1);\r\n    margin-top: 20px; border-radius: 1px;\r\n    overflow: hidden;\r\n  }\r\n  .keko-sphere-wrap .keko-loader-fill {\r\n    height: 100%;\r\n    background: #f0ece4;\r\n    width: 0%;\r\n    transition: width 0.3s ease;\r\n  }\r\n\r\n  .keko-sphere-wrap .keko-hint {\r\n    position: absolute;\r\n    bottom: 30px;\r\n    left: 50%;\r\n    transform: translateX(-50%);\r\n    z-index: 10;\r\n    font-size: 0.65rem;\r\n    letter-spacing: 0.25em;\r\n    text-transform: uppercase;\r\n    color: rgba(240,236,228,0.25);\r\n    pointer-events: none;\r\n    opacity: 0;\r\n    animation: kekoFadeIn 1.5s ease 2s forwards;\r\n  }\r\n\r\n  .keko-sphere-wrap .keko-lightbox {\r\n    position: absolute;\r\n    inset: 0;\r\n    z-index: 50;\r\n    background: rgba(10,10,10,0.92);\r\n    backdrop-filter: blur(20px);\r\n    display: flex;\r\n    align-items: center;\r\n    justify-content: center;\r\n    flex-direction: column;\r\n    opacity: 0;\r\n    pointer-events: none;\r\n    transition: opacity 0.4s ease;\r\n    cursor: pointer;\r\n  }\r\n  .keko-sphere-wrap .keko-lightbox.active { opacity: 1; pointer-events: all; }\r\n  .keko-sphere-wrap .keko-lightbox img {\r\n    max-width: 85%;\r\n    max-height: 70%;\r\n    object-fit: contain;\r\n    border-radius: 2px;\r\n    box-shadow: 0 40px 80px rgba(0,0,0,0.6);\r\n  }\r\n  .keko-sphere-wrap .keko-lightbox-title {\r\n    font-family: 'Syne', sans-serif;\r\n    font-weight: 700;\r\n    font-size: 1rem;\r\n    letter-spacing: 0.15em;\r\n    text-transform: uppercase;\r\n    margin-top: 20px;\r\n    color: #f0ece4;\r\n  }\r\n  .keko-sphere-wrap .keko-lightbox-link {\r\n    font-size: 0.7rem;\r\n    letter-spacing: 0.2em;\r\n    text-transform: uppercase;\r\n    color: rgba(240,236,228,0.5);\r\n    margin-top: 8px;\r\n    text-decoration: none;\r\n    pointer-events: all;\r\n    transition: color 0.3s;\r\n    border-bottom: 1px solid rgba(240,236,228,0.15);\r\n    padding-bottom: 2px;\r\n  }\r\n  .keko-sphere-wrap .keko-lightbox-link:hover { color: #f0ece4; border-color: #f0ece4; }\r\n\r\n  @keyframes kekoFadeIn {\r\n    from { opacity: 0; transform: translateX(-50%) translateY(10px); }\r\n    to { opacity: 1; transform: translateX(-50%) translateY(0); }\r\n  }\r\n\r\n  \/* \u2500\u2500 Mobile responsive \u2500\u2500 *\/\r\n  @media (max-width: 768px) {\r\n    .keko-sphere-wrap { min-height: 100svh; }\r\n    .keko-sphere-wrap .keko-title { top: max(20px, env(safe-area-inset-top, 20px)); }\r\n    .keko-sphere-wrap .keko-title h2 { letter-spacing: 0.2em; }\r\n    .keko-sphere-wrap .keko-title p { font-size: 0.6rem; letter-spacing: 0.15em; }\r\n    .keko-sphere-wrap .keko-hint { bottom: max(20px, env(safe-area-inset-bottom, 20px)); font-size: 0.55rem; letter-spacing: 0.15em; }\r\n    .keko-sphere-wrap .keko-lightbox {\r\n      padding: 20px;\r\n      padding-top: max(20px, env(safe-area-inset-top, 20px));\r\n      padding-bottom: max(20px, env(safe-area-inset-bottom, 20px));\r\n    }\r\n    .keko-sphere-wrap .keko-lightbox img { max-width: 95%; max-height: 60%; }\r\n    .keko-sphere-wrap .keko-lightbox-title { font-size: 0.8rem; margin-top: 14px; }\r\n    .keko-sphere-wrap .keko-lightbox-link { font-size: 0.6rem; }\r\n    .keko-sphere-wrap .keko-tooltip-inner { font-size: 0.7rem; padding: 6px 12px; }\r\n  }\r\n  @media (max-width: 400px) {\r\n    .keko-sphere-wrap { min-height: 500px; }\r\n    .keko-sphere-wrap .keko-title h2 { font-size: 1rem; }\r\n  }\r\n<\/style>\r\n\r\n<div class=\"keko-sphere-wrap\" id=\"kekoSphereWrap\">\r\n  <div class=\"keko-loader\" id=\"kekoLoader\">\r\n    <div class=\"keko-loader-text\">Cargando portfolio<\/div>\r\n    <div class=\"keko-loader-bar\"><div class=\"keko-loader-fill\" id=\"kekoLoaderFill\"><\/div><\/div>\r\n  <\/div>\r\n  <div class=\"keko-title\">\r\n    <h2>Portfolio<\/h2>\r\n    <p>Explora el universo visual<\/p>\r\n  <\/div>\r\n  <div class=\"keko-vignette\"><\/div>\r\n  <div class=\"keko-tooltip\" id=\"kekoTooltip\">\r\n    <div class=\"keko-tooltip-inner\" id=\"kekoTooltipInner\"><\/div>\r\n    <div class=\"keko-tooltip-arrow\"><\/div>\r\n  <\/div>\r\n  <div class=\"keko-lightbox\" id=\"kekoLightbox\">\r\n    <img decoding=\"async\" id=\"kekoLbImg\" src=\"\" alt=\"\">\r\n    <div class=\"keko-lightbox-title\" id=\"kekoLbTitle\"><\/div>\r\n    <a class=\"keko-lightbox-link\" id=\"kekoLbLink\" href=\"#\" target=\"_blank\">Ver proyecto \u2192<\/a>\r\n  <\/div>\r\n  <div class=\"keko-hint\" id=\"kekoHint\"><\/div>\r\n<\/div>\r\n\r\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/three.js\/r128\/three.min.js\"><\/script>\r\n<script>\r\n(function() {\r\n  \/* \u2500\u2500 MOBILE DETECTION \u2500\u2500 *\/\r\n  const isMobile = \/Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini\/i.test(navigator.userAgent) || window.innerWidth < 768;\r\n  const isLowEnd = isMobile && (navigator.hardwareConcurrency || 4) <= 4;\r\n\r\n  \/* \u2500\u2500 CONFIG (adaptivo) \u2500\u2500 *\/\r\n  const API_BASE = 'https:\/\/kekoph.com\/?rest_route=\/wp\/v2\/portfolio-item&_embed=1';\r\n  const TOTAL_PHOTOS = isLowEnd ? 80 : 130;\r\n  const TEX_SIZE = isMobile ? 128 : 192;\r\n  const SPHERE_RADIUS = 10;\r\n  const NODE_BASE_SIZE = 0.52;\r\n  const LINE_OPACITY = 0.03;\r\n\r\n  \/* \u2500\u2500 DEPTH SIZING \u2500\u2500 *\/\r\n  const DEPTH_SCALE_MIN = 0.4;\r\n  const DEPTH_SCALE_MAX = 1.8;\r\n\r\n  \/* \u2500\u2500 ZOOM CONFIG \u2500\u2500 *\/\r\n  const ZOOM_MIN = 8;\r\n  const ZOOM_MAX = 32;\r\n  const ZOOM_SPEED = 0.8;\r\n  const ZOOM_SMOOTH = 0.08;\r\n\r\n  const wrap = document.getElementById('kekoSphereWrap');\r\n  const loaderFill = document.getElementById('kekoLoaderFill');\r\n  const tooltip = document.getElementById('kekoTooltip');\r\n  const tooltipInner = document.getElementById('kekoTooltipInner');\r\n\r\n  \/* \u2500\u2500 HINT ADAPTIVO \u2500\u2500 *\/\r\n  document.getElementById('kekoHint').textContent = isMobile\r\n    ? 'Arrastra para rotar \u00b7 Pellizca para zoom \u00b7 Toca una foto para ampliar'\r\n    : 'Arrastra para rotar \u00b7 Scroll para zoom \u00b7 Click en una foto para ampliar';\r\n\r\n  let scene, camera, renderer, raycaster, mouse;\r\n  let sphereGroup, photoSprites = [], portfolioData = [];\r\n  let hoveredSprite = null, isDragging = false;\r\n  let previousMouse = {x:0,y:0};\r\n  let loadedCount = 0;\r\n\r\n  \/* \u2500\u2500 PRE-ALLOCATED VECTORS (avoid GC in animate loop) \u2500\u2500 *\/\r\n  const _camWorldPos = new THREE.Vector3();\r\n  const _spriteWorldPos = new THREE.Vector3();\r\n  const _normal = new THREE.Vector3();\r\n\r\n  \/* \u2500\u2500 CACHED RECT (avoid layout thrashing) \u2500\u2500 *\/\r\n  let cachedRect = null;\r\n  function updateRect() { cachedRect = wrap.getBoundingClientRect(); }\r\n  function getRect() { if(!cachedRect) updateRect(); return cachedRect; }\r\n\r\n  \/* \u2500\u2500 INERTIA SYSTEM \u2500\u2500 *\/\r\n  let velX = 0, velY = 0;                \/* angular velocity (persists after release) *\/\r\n  let lastDx = 0, lastDy = 0;            \/* last frame drag delta *\/\r\n  const DRAG_SENS = 0.004;               \/* drag pixels \u2192 radians *\/\r\n  const FRICTION = 0.985;                \/* normal friction (close to 1 = long glide) *\/\r\n  const FRICTION_HOVER = 0.94;           \/* friction when hovering a photo (brakes hard) *\/\r\n  const IDLE_SPEED = 0.0006;             \/* gentle auto-spin speed *\/\r\n  const IDLE_DELAY = 180;                \/* frames (~3s) before auto-spin kicks in *\/\r\n  const IDLE_BLEND = 0.005;              \/* how fast auto-spin fades in *\/\r\n  let idleFrames = 0;                    \/* frames since last interaction *\/\r\n  let lastDragDir = {x:0, y:1};          \/* direction of last user drag (for idle spin) *\/\r\n  let dragStartX = 0, dragStartY = 0;    \/* for click vs drag detection *\/\r\n  const CLICK_THRESHOLD = 6;             \/* pixels \u2014 less = click, more = drag *\/\r\n\r\n  \/* \u2500\u2500 ZOOM STATE (nuevo) \u2500\u2500 *\/\r\n  let targetZoom, currentZoom;\r\n\r\n  function clampZoom(z) { return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); }\r\n\r\n  \/* \u2500\u2500 API PAGINADA (nuevo \u2014 WP limita a 100 per_page) \u2500\u2500 *\/\r\n  function parseItem(item) {\r\n    const media = item._embedded?.['wp:featuredmedia']?.[0];\r\n    const sizes = media?.media_details?.sizes;\r\n    const thumb = sizes?.medium?.source_url || sizes?.thumbnail?.source_url || media?.source_url || '';\r\n    const full = media?.source_url || thumb;\r\n    return { title: item.title.rendered, link: item.link, thumb, full };\r\n  }\r\n\r\n  async function fetchPortfolio() {\r\n    try {\r\n      const allItems = [];\r\n      const perPage = 100;\r\n      const pages = Math.ceil(TOTAL_PHOTOS \/ perPage);\r\n      for (let page = 1; page <= pages; page++) {\r\n        const url = API_BASE + '&per_page=' + Math.min(perPage, TOTAL_PHOTOS - allItems.length) + '&page=' + page;\r\n        const res = await fetch(url);\r\n        if (!res.ok) break;\r\n        const data = await res.json();\r\n        if (!data.length) break;\r\n        allItems.push(...data);\r\n        if (data.length < perPage) break;\r\n      }\r\n      portfolioData = allItems.map(parseItem).filter(d => d.thumb);\r\n      console.log('Portfolio loaded:', portfolioData.length, 'fotos');\r\n      return portfolioData;\r\n    } catch(e) {\r\n      console.error('API error', e);\r\n      portfolioData = [\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\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'},\r\n      ];\r\n      return portfolioData;\r\n    }\r\n  }\r\n\r\n  \/* \u2500\u2500 Todo lo dem\u00e1s: ID\u00c9NTICO al original \u2500\u2500 *\/\r\n\r\n  function fibonacciSphere(n, radius) {\r\n    const pts = [], ga = Math.PI*(3-Math.sqrt(5));\r\n    for(let i=0;i<n;i++){const y=1-(i\/(n-1))*2,r=Math.sqrt(1-y*y),t=ga*i;pts.push(new THREE.Vector3(Math.cos(t)*r*radius,y*radius,Math.sin(t)*r*radius));}\r\n    return pts;\r\n  }\r\n\r\n  function buildConnections(pts, maxD) {\r\n    const e=[];for(let i=0;i<pts.length;i++)for(let j=i+1;j<pts.length;j++)if(pts[i].distanceTo(pts[j])<maxD)e.push([i,j]);return e;\r\n  }\r\n\r\n  function loadTexture(url) {\r\n    return new Promise(resolve => {\r\n      const img = new Image(); img.crossOrigin='anonymous';\r\n      img.onload = () => {\r\n        const s=TEX_SIZE,c=document.createElement('canvas');c.width=s;c.height=s;\r\n        const ctx=c.getContext('2d');\r\n        ctx.beginPath();ctx.arc(s\/2,s\/2,s\/2,0,Math.PI*2);ctx.clip();\r\n        const a=img.width\/img.height;let sw,sh,sx,sy;\r\n        if(a>1){sh=img.height;sw=sh;sx=(img.width-sw)\/2;sy=0;}else{sw=img.width;sh=sw;sx=0;sy=(img.height-sh)\/2;}\r\n        ctx.drawImage(img,sx,sy,sw,sh,0,0,s,s);\r\n        ctx.beginPath();ctx.arc(s\/2,s\/2,s\/2-1,0,Math.PI*2);ctx.strokeStyle='rgba(240,236,228,0.3)';ctx.lineWidth=2;ctx.stroke();\r\n        resolve(new THREE.CanvasTexture(c));\r\n      };\r\n      img.onerror = () => {\r\n        const s=TEX_SIZE,c=document.createElement('canvas');c.width=s;c.height=s;\r\n        const ctx=c.getContext('2d');ctx.beginPath();ctx.arc(s\/2,s\/2,s\/2-2,0,Math.PI*2);ctx.fillStyle='#2a2a2a';ctx.fill();\r\n        resolve(new THREE.CanvasTexture(c));\r\n      };\r\n      img.src = url;\r\n    });\r\n  }\r\n\r\n  function createDotTex(filled) {\r\n    const s=64,c=document.createElement('canvas');c.width=s;c.height=s;\r\n    const ctx=c.getContext('2d');ctx.beginPath();ctx.arc(s\/2,s\/2,filled?s\/4:s\/6,0,Math.PI*2);\r\n    if(filled){ctx.fillStyle='rgba(240,236,228,0.15)';ctx.fill();}\r\n    ctx.strokeStyle='rgba(240,236,228,0.12)';ctx.lineWidth=1.5;ctx.stroke();\r\n    return new THREE.CanvasTexture(c);\r\n  }\r\n\r\n  function initScene() {\r\n    const w=wrap.clientWidth, h=wrap.clientHeight;\r\n    scene=new THREE.Scene();\r\n    camera=new THREE.PerspectiveCamera(50,w\/h,0.1,100);\r\n    currentZoom = w<768 ? 22 : 16;\r\n    targetZoom = currentZoom;\r\n    camera.position.z = currentZoom;\r\n    renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});\r\n    renderer.setSize(w,h);\r\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));\r\n    renderer.setClearColor(0x0a0a0a,1);\r\n    wrap.insertBefore(renderer.domElement, wrap.firstChild);\r\n    raycaster=new THREE.Raycaster();\r\n    mouse=new THREE.Vector2(-999,-999);\r\n    sphereGroup=new THREE.Group();\r\n    scene.add(sphereGroup);\r\n    scene.add(new THREE.AmbientLight(0xffffff,0.6));\r\n    const pl=new THREE.PointLight(0xf0ece4,0.4,30);pl.position.set(5,5,10);scene.add(pl);\r\n  }\r\n\r\n  async function buildSphere(data) {\r\n    const n=data.length, total=Math.max(Math.floor(n*1.25),40);\r\n    const pts=fibonacciSphere(total,SPHERE_RADIUS);\r\n    const photoIdx=[];const step=total\/n;for(let i=0;i<n;i++)photoIdx.push(Math.floor(i*step));\r\n    const edges=buildConnections(pts,SPHERE_RADIUS*0.38);\r\n    const lp=[];for(const[a,b]of edges){lp.push(pts[a].x,pts[a].y,pts[a].z,pts[b].x,pts[b].y,pts[b].z);}\r\n    const lg=new THREE.BufferGeometry();lg.setAttribute('position',new THREE.Float32BufferAttribute(lp,3));\r\n    const lineMesh=new THREE.LineSegments(lg,new THREE.LineBasicMaterial({color:0xf0ece4,transparent:true,opacity:LINE_OPACITY,depthWrite:false}));\r\n    lineMesh.renderOrder=0;\r\n    sphereGroup.add(lineMesh);\r\n    const dotA=createDotTex(false),dotB=createDotTex(true);\r\n    for(let i=0;i<total;i++){\r\n      const pi=photoIdx.indexOf(i),isP=pi!==-1;\r\n      let mat;\r\n      if(isP){const tex=await loadTexture(data[pi].thumb);mat=new THREE.SpriteMaterial({map:tex,transparent:true,sizeAttenuation:true,depthTest:false});loadedCount++;loaderFill.style.width=(loadedCount\/n*100)+'%';}\r\n      else{mat=new THREE.SpriteMaterial({map:Math.random()>0.6?dotB:dotA,transparent:true,sizeAttenuation:true});}\r\n      const sp=new THREE.Sprite(mat);sp.position.copy(pts[i]);\r\n      sp.renderOrder=isP?2:1;\r\n      const sz=isP?NODE_BASE_SIZE:0.08+Math.random()*0.06;sp.scale.set(sz,sz,1);\r\n      sp.userData={isPhoto:isP,baseSize:sz,index:pi,originalPos:pts[i].clone()};\r\n      sphereGroup.add(sp);if(isP)photoSprites.push(sp);\r\n    }\r\n    setTimeout(()=>document.getElementById('kekoLoader').classList.add('hidden'),400);\r\n  }\r\n\r\n  \/* \u2500\u2500 Events \u2500\u2500 *\/\r\n\r\n  wrap.addEventListener('mousemove',e=>{\r\n    const r=getRect();\r\n    mouse.x=((e.clientX-r.left)\/r.width)*2-1;\r\n    mouse.y=-((e.clientY-r.top)\/r.height)*2+1;\r\n    if(isDragging){\r\n      lastDx=(e.clientX-previousMouse.x)*DRAG_SENS;\r\n      lastDy=(e.clientY-previousMouse.y)*DRAG_SENS;\r\n      \/* Apply drag delta directly to rotation this frame *\/\r\n      sphereGroup.rotation.y+=lastDx;\r\n      sphereGroup.rotation.x+=lastDy;\r\n      idleFrames=0;\r\n    }\r\n    previousMouse.x=e.clientX;previousMouse.y=e.clientY;\r\n  });\r\n\r\n  wrap.addEventListener('mousedown',e=>{\r\n    isDragging=true;\r\n    lastDx=0;lastDy=0;\r\n    velX=0;velY=0; \/* stop any existing momentum *\/\r\n    dragStartX=e.clientX;dragStartY=e.clientY;\r\n    previousMouse.x=e.clientX;previousMouse.y=e.clientY;\r\n    wrap.style.cursor='grabbing';\r\n  });\r\n\r\n  wrap.addEventListener('mouseup',e=>{\r\n    if(isDragging){\r\n      \/* Transfer last drag delta as release velocity *\/\r\n      velX=lastDy;velY=lastDx;\r\n      \/* Remember drag direction for idle auto-spin *\/\r\n      const mag=Math.hypot(velX,velY);\r\n      if(mag>0.0001){lastDragDir.x=velX\/mag;lastDragDir.y=velY\/mag;}\r\n      lastDx=0;lastDy=0;\r\n    }\r\n    isDragging=false;\r\n    wrap.style.cursor='crosshair';\r\n  });\r\n\r\n  wrap.addEventListener('click',e=>{\r\n    \/* Only fire click if mouse barely moved (not a drag) *\/\r\n    const dist=Math.hypot(e.clientX-dragStartX,e.clientY-dragStartY);\r\n    if(dist>CLICK_THRESHOLD)return;\r\n    if(hoveredSprite&&hoveredSprite.userData.isPhoto)openLb(portfolioData[hoveredSprite.userData.index]);\r\n  });\r\n\r\n  \/* \u2500\u2500 ZOOM: Rueda del rat\u00f3n (nuevo) \u2500\u2500 *\/\r\n  wrap.addEventListener('wheel', e => {\r\n    e.preventDefault();\r\n    const delta = e.deltaY > 0 ? ZOOM_SPEED : -ZOOM_SPEED;\r\n    targetZoom = clampZoom(targetZoom + delta);\r\n  }, {passive: false});\r\n\r\n  \/* \u2500\u2500 Touch: inertia + pinch zoom \u2500\u2500 *\/\r\n  let touchStart={x:0,y:0};\r\n  let lastPinchDist = 0;\r\n  let isTouchZooming = false;\r\n  function getPinchDist(touches) {\r\n    return Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);\r\n  }\r\n  wrap.addEventListener('touchstart',e=>{\r\n    if(e.touches.length===2){\r\n      isTouchZooming=true;isDragging=false;\r\n      lastPinchDist=getPinchDist(e.touches);\r\n    } else if(e.touches.length===1){\r\n      isDragging=true;isTouchZooming=false;\r\n      lastDx=0;lastDy=0;velX=0;velY=0;\r\n      touchStart.x=e.touches[0].clientX;touchStart.y=e.touches[0].clientY;\r\n      previousMouse.x=touchStart.x;previousMouse.y=touchStart.y;\r\n    }\r\n  },{passive:false});\r\n  wrap.addEventListener('touchmove',e=>{\r\n    e.preventDefault(); \/* prevent page scroll while interacting with sphere *\/\r\n    if(e.touches.length===2&&isTouchZooming){\r\n      const dist=getPinchDist(e.touches);\r\n      targetZoom=clampZoom(targetZoom+(lastPinchDist-dist)*0.04);\r\n      lastPinchDist=dist;\r\n    } else if(e.touches.length===1&&isDragging&&!isTouchZooming){\r\n      const r=getRect();\r\n      lastDx=(e.touches[0].clientX-previousMouse.x)*DRAG_SENS;\r\n      lastDy=(e.touches[0].clientY-previousMouse.y)*DRAG_SENS;\r\n      sphereGroup.rotation.y+=lastDx;\r\n      sphereGroup.rotation.x+=lastDy;\r\n      idleFrames=0;\r\n      previousMouse.x=e.touches[0].clientX;previousMouse.y=e.touches[0].clientY;\r\n      mouse.x=((e.touches[0].clientX-r.left)\/r.width)*2-1;mouse.y=-((e.touches[0].clientY-r.top)\/r.height)*2+1;\r\n    }\r\n  },{passive:false});\r\n  wrap.addEventListener('touchend',e=>{\r\n    if(e.touches.length<2)isTouchZooming=false;\r\n    if(e.touches.length===0){\r\n      if(isDragging){\r\n        velX=lastDy;velY=lastDx;\r\n        const mag=Math.hypot(velX,velY);\r\n        if(mag>0.0001){lastDragDir.x=velX\/mag;lastDragDir.y=velY\/mag;}\r\n        lastDx=0;lastDy=0;\r\n      }\r\n      isDragging=false;\r\n      const d=Math.hypot(previousMouse.x-touchStart.x,previousMouse.y-touchStart.y);\r\n      if(d<CLICK_THRESHOLD&&hoveredSprite&&hoveredSprite.userData.isPhoto)openLb(portfolioData[hoveredSprite.userData.index]);\r\n    }\r\n  });\r\n\r\n  function openLb(item){\r\n    const lb=document.getElementById('kekoLightbox');\r\n    document.getElementById('kekoLbImg').src=item.full;\r\n    document.getElementById('kekoLbTitle').textContent=item.title;\r\n    const link=document.getElementById('kekoLbLink');link.href=item.link;\r\n    lb.classList.add('active');\r\n  }\r\n  document.getElementById('kekoLightbox').addEventListener('click',e=>{\r\n    if(e.target.tagName!=='A')document.getElementById('kekoLightbox').classList.remove('active');\r\n  });\r\n  document.addEventListener('keydown',e=>{if(e.key==='Escape')document.getElementById('kekoLightbox').classList.remove('active');});\r\n\r\n  \/* \u2500\u2500 Animate \u2500\u2500 *\/\r\n  function animate(){\r\n    requestAnimationFrame(animate);\r\n\r\n    if(!isDragging){\r\n      \/* Choose friction: brake harder when hovering a photo *\/\r\n      const f = hoveredSprite ? FRICTION_HOVER : FRICTION;\r\n      velX *= f;\r\n      velY *= f;\r\n\r\n      \/* Kill tiny residual *\/\r\n      if(Math.abs(velX)<0.000005) velX=0;\r\n      if(Math.abs(velY)<0.000005) velY=0;\r\n\r\n      \/* Apply momentum *\/\r\n      sphereGroup.rotation.x += velX;\r\n      sphereGroup.rotation.y += velY;\r\n\r\n      \/* Idle auto-spin: after ~3s, gently resume spinning in last drag direction *\/\r\n      idleFrames++;\r\n      if(idleFrames > IDLE_DELAY){\r\n        const blend = Math.min((idleFrames - IDLE_DELAY) \/ 300, 1) * IDLE_BLEND;\r\n        velX += (lastDragDir.x * IDLE_SPEED - velX) * blend;\r\n        velY += (lastDragDir.y * IDLE_SPEED - velY) * blend;\r\n      }\r\n    }\r\n\r\n    \/* Smooth zoom (nuevo) *\/\r\n    currentZoom += (targetZoom - currentZoom) * ZOOM_SMOOTH;\r\n    camera.position.z = currentZoom;\r\n\r\n    raycaster.setFromCamera(mouse,camera);\r\n    const hits=raycaster.intersectObjects(photoSprites);\r\n    if(hoveredSprite&&(!hits.length||hits[0].object!==hoveredSprite)){hoveredSprite=null;tooltip.classList.remove('visible');if(!isDragging)wrap.style.cursor='crosshair';}\r\n    if(hits.length>0&&hits[0].object.userData.isPhoto){\r\n      hoveredSprite=hits[0].object;\r\n      if(!isDragging)wrap.style.cursor='pointer';\r\n      _spriteWorldPos.copy(hoveredSprite.position).applyMatrix4(sphereGroup.matrixWorld).project(camera);\r\n      const r=getRect();tooltip.style.left=(_spriteWorldPos.x*0.5+0.5)*r.width+'px';tooltip.style.top=(-_spriteWorldPos.y*0.5+0.5)*r.height+'px';\r\n      tooltipInner.textContent=portfolioData[hoveredSprite.userData.index].title;tooltip.classList.add('visible');\r\n    }\r\n\r\n    \/* Depth-based sizing + breathing (zero-allocation) *\/\r\n    const t=Date.now()*0.001;\r\n    camera.getWorldPosition(_camWorldPos);\r\n    const maxDist=currentZoom+SPHERE_RADIUS;\r\n    const minDist=Math.max(currentZoom-SPHERE_RADIUS,1);\r\n    const distRange=maxDist-minDist;\r\n    for(let i=0;i<photoSprites.length;i++){\r\n      const s=photoSprites[i];\r\n      \/* Breathing \u2014 reuse _normal instead of clone+normalize *\/\r\n      const o=Math.sin(t+i*0.8)*0.02;\r\n      _normal.copy(s.userData.originalPos).normalize();\r\n      s.position.copy(s.userData.originalPos).addScaledVector(_normal,o);\r\n      \/* Depth \u2014 reuse _spriteWorldPos instead of clone *\/\r\n      _spriteWorldPos.copy(s.position).applyMatrix4(sphereGroup.matrixWorld);\r\n      const dist=_camWorldPos.distanceTo(_spriteWorldPos);\r\n      const depthT=1-Math.min(Math.max((dist-minDist)\/distRange,0),1);\r\n      const depthScale=DEPTH_SCALE_MIN+(DEPTH_SCALE_MAX-DEPTH_SCALE_MIN)*depthT;\r\n      const base=s.userData.baseSize*depthScale;\r\n      const sz=(s===hoveredSprite)?base*1.4:base;\r\n      s.scale.set(sz,sz,1);\r\n      if(s.userData.isPhoto){s.material.opacity=0.5+depthT*0.5;}\r\n    }\r\n    renderer.render(scene,camera);\r\n  }\r\n\r\n  window.addEventListener('resize',()=>{updateRect();const w=wrap.clientWidth,h=wrap.clientHeight;camera.aspect=w\/h;camera.updateProjectionMatrix();renderer.setSize(w,h);});\r\n\r\n  (async()=>{initScene();updateRect();const d=await fetchPortfolio();await buildSphere(d);animate();})();\r\n})();\r\n<\/script>\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<\/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":94,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8971\/revisions"}],"predecessor-version":[{"id":9066,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8971\/revisions\/9066"}],"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}]}}