{"id":8673,"date":"2026-02-11T13:34:21","date_gmt":"2026-02-11T12:34:21","guid":{"rendered":"https:\/\/kekoph.com\/?page_id=8673"},"modified":"2026-03-17T16:18:51","modified_gmt":"2026-03-17T15:18:51","slug":"portfolio-main","status":"publish","type":"page","link":"https:\/\/kekoph.com\/?page_id=8673","title":{"rendered":"PORTFOLIO MAIN"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"8673\" class=\"elementor elementor-8673\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-10cb3e0 e-con-full portfolio-cloud-wrap e-flex qodef-elementor-content-no e-con e-parent\" data-id=\"10cb3e0\" data-element_type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-1b5902e elementor-widget elementor-widget-html\" data-id=\"1b5902e\" data-element_type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<div id=\"portfolio-cloud-stage\">\r\n  <div id=\"portfolio-cloud\"><\/div>\r\n  <div class=\"cloud-loading\" id=\"cloud-loading\">Loading portfolio...<\/div>\r\n  <div class=\"cloud-tooltip\" id=\"cloud-tooltip\"><\/div>\r\n<\/div>\r\n\r\n<style>\r\n  #portfolio-cloud-stage{\r\n    position: relative;\r\n    width: 100%;\r\n    min-height: 100vh;\r\n    height: 100vh;\r\n    overflow: hidden;\r\n    background: #000;\r\n  }\r\n\r\n  #portfolio-cloud{\r\n    position: relative;\r\n    width: 100%;\r\n    height: 100%;\r\n    overflow: hidden;\r\n    background:\r\n      radial-gradient(circle at center, rgba(255,255,255,0.028) 0%, rgba(255,255,255,0.01) 34%, rgba(0,0,0,0) 64%),\r\n      #000;\r\n  }\r\n\r\n  #portfolio-cloud canvas{\r\n    display: block;\r\n    width: 100%;\r\n    height: 100%;\r\n  }\r\n\r\n  .cloud-loading{\r\n    position: absolute;\r\n    inset: 0;\r\n    display: flex;\r\n    align-items: center;\r\n    justify-content: center;\r\n    color: rgba(255,255,255,.75);\r\n    font-size: 13px;\r\n    letter-spacing: .12em;\r\n    text-transform: uppercase;\r\n    z-index: 20;\r\n    pointer-events: none;\r\n  }\r\n\r\n  .cloud-tooltip{\r\n    position: fixed;\r\n    z-index: 9999;\r\n    pointer-events: none;\r\n    transform: translate(-50%, -120%);\r\n    background: rgba(0,0,0,.84);\r\n    color: #fff;\r\n    padding: 8px 10px;\r\n    border: 1px solid rgba(255,255,255,.14);\r\n    border-radius: 10px;\r\n    font-size: 12px;\r\n    line-height: 1.2;\r\n    opacity: 0;\r\n    transition: opacity .18s ease;\r\n    white-space: nowrap;\r\n    backdrop-filter: blur(8px);\r\n    box-shadow: 0 8px 30px rgba(0,0,0,.35);\r\n  }\r\n\r\n  .cloud-tooltip.is-visible{\r\n    opacity: 1;\r\n  }\r\n\r\n  @media (max-width: 1024px){\r\n    #portfolio-cloud-stage{\r\n      min-height: 100svh;\r\n      height: 100svh;\r\n    }\r\n  }\r\n\r\n  @media (max-width: 767px){\r\n    .cloud-tooltip{\r\n      display: none;\r\n    }\r\n  }\r\n<\/style>\r\n\r\n<script type=\"module\">\r\n  import * as THREE from 'https:\/\/unpkg.com\/three@0.160.0\/build\/three.module.js';\r\n\r\n  const API_ENDPOINT = '\/?rest_route=\/wp\/v2\/portfolio-item';\r\n\r\n  const container = document.getElementById('portfolio-cloud');\r\n  const loadingEl = document.getElementById('cloud-loading');\r\n  const tooltipEl = document.getElementById('cloud-tooltip');\r\n\r\n  let scene, camera, renderer, raycaster, worldGroup;\r\n  let sprites = [];\r\n  let hoveredSprite = null;\r\n  let mouse = new THREE.Vector2(-10, -10);\r\n  let targetMouseX = 0;\r\n  let targetMouseY = 0;\r\n  let currentMouseX = 0;\r\n  let currentMouseY = 0;\r\n  let animationId = null;\r\n  let resizeTimer = null;\r\n  let cachedItems = null;\r\n\r\n  function getDeviceFlags() {\r\n    return {\r\n      isMobile: window.matchMedia('(max-width: 767px)').matches,\r\n      isTablet: window.matchMedia('(min-width: 768px) and (max-width: 1024px)').matches\r\n    };\r\n  }\r\n\r\n  function getSettings() {\r\n    const { isMobile, isTablet } = getDeviceFlags();\r\n\r\n    return {\r\n      isMobile,\r\n      isTablet,\r\n\r\n      \/\/ M\u00e1s limpio y consistente\r\n      maxItems: isMobile ? 58 : isTablet ? 64 : 92,\r\n\r\n      \/\/ C\u00e1mara\r\n      cameraZ: isMobile ? 18.0 : isTablet ? 23 : 27,\r\n\r\n      \/\/ Mundo un poco m\u00e1s compacto para cerrar huecos\r\n      worldWidth: isMobile ? 15.2 : isTablet ? 36 : 54,\r\n      worldHeight: isMobile ? 22.8 : isTablet ? 26 : 22,\r\n      depth: isMobile ? 1.0 : isTablet ? 3.0 : 4.2,\r\n\r\n      \/\/ Separaci\u00f3n peque\u00f1a pero no cero\r\n      collisionPadding: isMobile ? 0.010 : isTablet ? 0.035 : 0.045,\r\n\r\n      \/\/ M\u00e1s intentos para encajar sin montarse\r\n      maxPlacementAttempts: isMobile ? 950 : 500,\r\n\r\n      \/\/ Menos extremos de jerarqu\u00eda\r\n      heroCount: isMobile ? 2 : 6,\r\n      mediumCount: isMobile ? 20 : 24,\r\n\r\n      \/\/ Grid denso pero estable\r\n      cols: isMobile ? 8 : isTablet ? 10 : 13,\r\n      rows: isMobile ? 12 : isTablet ? 8 : 7,\r\n\r\n      \/\/ Movimiento muy suave\r\n      driftX: isMobile ? 0.38 : 1.6,\r\n      driftY: isMobile ? 0.28 : 1.1,\r\n      driftRotation: isMobile ? 0.0025 : 0.012,\r\n\r\n      \/\/ Control extra de solape visual\r\n      overlapBiasX: isMobile ? 1.06 : 1.04,\r\n      overlapBiasY: isMobile ? 1.08 : 1.05\r\n    };\r\n  }\r\n\r\n  let SETTINGS = getSettings();\r\n\r\n  async function fetchAllPortfolioItems() {\r\n    if (cachedItems) return cachedItems;\r\n\r\n    const firstUrl = `${API_ENDPOINT}&per_page=100&page=1&_embed=1`;\r\n    const firstRes = await fetch(firstUrl);\r\n\r\n    if (!firstRes.ok) {\r\n      throw new Error('No se pudo leer la API del portfolio');\r\n    }\r\n\r\n    const totalPages = parseInt(firstRes.headers.get('X-WP-TotalPages') || '1', 10);\r\n    let items = await firstRes.json();\r\n\r\n    if (totalPages > 1) {\r\n      const requests = [];\r\n      for (let page = 2; page <= totalPages; page++) {\r\n        requests.push(\r\n          fetch(`${API_ENDPOINT}&per_page=100&page=${page}&_embed=1`).then((r) => r.json())\r\n        );\r\n      }\r\n      const rest = await Promise.all(requests);\r\n      rest.forEach((arr) => {\r\n        items = items.concat(arr);\r\n      });\r\n    }\r\n\r\n    cachedItems = items;\r\n    return items;\r\n  }\r\n\r\n  function decodeHTML(html) {\r\n    const txt = document.createElement('textarea');\r\n    txt.innerHTML = html;\r\n    return txt.value;\r\n  }\r\n\r\n  function shuffle(array) {\r\n    const arr = [...array];\r\n    for (let i = arr.length - 1; i > 0; i--) {\r\n      const j = Math.floor(Math.random() * (i + 1));\r\n      [arr[i], arr[j]] = [arr[j], arr[i]];\r\n    }\r\n    return arr;\r\n  }\r\n\r\n  function normalizeItems(items) {\r\n    return items\r\n      .map((item) => {\r\n        const media = item?._embedded?.['wp:featuredmedia']?.[0];\r\n        const sizes = media?.media_details?.sizes || {};\r\n\r\n        const chosen =\r\n          sizes?.medium_large ||\r\n          sizes?.large ||\r\n          sizes?.medium ||\r\n          null;\r\n\r\n        const image = chosen?.source_url || media?.source_url || null;\r\n        const width = chosen?.width || media?.media_details?.width || 1;\r\n        const height = chosen?.height || media?.media_details?.height || 1;\r\n        const aspect = width \/ height;\r\n\r\n        return {\r\n          id: item.id,\r\n          title: item?.title?.rendered ? decodeHTML(item.title.rendered) : 'Untitled',\r\n          url: item.link || '#',\r\n          image,\r\n          width,\r\n          height,\r\n          aspect\r\n        };\r\n      })\r\n      .filter((item) => !!item.image);\r\n  }\r\n\r\n  function initThree() {\r\n    scene = new THREE.Scene();\r\n\r\n    camera = new THREE.PerspectiveCamera(\r\n      40,\r\n      container.clientWidth \/ container.clientHeight,\r\n      0.1,\r\n      1000\r\n    );\r\n    camera.position.z = SETTINGS.cameraZ;\r\n\r\n    renderer = new THREE.WebGLRenderer({\r\n      antialias: true,\r\n      alpha: true,\r\n      powerPreference: 'high-performance'\r\n    });\r\n\r\n    renderer.setSize(container.clientWidth, container.clientHeight);\r\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, SETTINGS.isMobile ? 1.25 : 2));\r\n    renderer.outputColorSpace = THREE.SRGBColorSpace;\r\n\r\n    container.innerHTML = '';\r\n    container.appendChild(renderer.domElement);\r\n\r\n    raycaster = new THREE.Raycaster();\r\n    worldGroup = new THREE.Group();\r\n    scene.add(worldGroup);\r\n  }\r\n\r\n  function getTierForIndex(index) {\r\n    if (index < SETTINGS.heroCount) return 'hero';\r\n    if (index < SETTINGS.heroCount + SETTINGS.mediumCount) return 'medium';\r\n    return 'small';\r\n  }\r\n\r\n  function getSizeForTier(tier) {\r\n    if (SETTINGS.isMobile) {\r\n      if (tier === 'hero') {\r\n        return { min: 2.15, max: 2.75 };\r\n      }\r\n      if (tier === 'medium') {\r\n        return { min: 1.45, max: 1.95 };\r\n      }\r\n      \/\/ m\u00ednimo mediano, nada diminuto\r\n      return { min: 1.08, max: 1.42 };\r\n    }\r\n\r\n    if (tier === 'hero') {\r\n      return { min: 5.0, max: 7.2 };\r\n    }\r\n\r\n    if (tier === 'medium') {\r\n      return { min: 3.1, max: 4.8 };\r\n    }\r\n\r\n    return { min: 2.05, max: 2.95 };\r\n  }\r\n\r\n  function isOverlapping(candidate, placed) {\r\n    for (const p of placed) {\r\n      const dx = Math.abs(candidate.x - p.x);\r\n      const dy = Math.abs(candidate.y - p.y);\r\n\r\n      const minDistX = ((candidate.width + p.width) * 0.5 + SETTINGS.collisionPadding) * SETTINGS.overlapBiasX;\r\n      const minDistY = ((candidate.height + p.height) * 0.5 + SETTINGS.collisionPadding) * SETTINGS.overlapBiasY;\r\n\r\n      if (dx < minDistX && dy < minDistY) return true;\r\n    }\r\n    return false;\r\n  }\r\n\r\n  function insideWorld(x, y, width, height) {\r\n    const halfW = SETTINGS.worldWidth * 0.5;\r\n    const halfH = SETTINGS.worldHeight * 0.5;\r\n    const w = width * 0.5;\r\n    const h = height * 0.5;\r\n\r\n    if (x - w < -halfW) return false;\r\n    if (x + w > halfW) return false;\r\n    if (y - h < -halfH) return false;\r\n    if (y + h > halfH) return false;\r\n\r\n    return true;\r\n  }\r\n\r\n  function createAnchorGrid() {\r\n    const anchors = [];\r\n    const halfW = SETTINGS.worldWidth * 0.5;\r\n    const halfH = SETTINGS.worldHeight * 0.5;\r\n\r\n    for (let row = 0; row < SETTINGS.rows; row++) {\r\n      for (let col = 0; col < SETTINGS.cols; col++) {\r\n        const x = THREE.MathUtils.mapLinear(col, 0, SETTINGS.cols - 1, -halfW, halfW);\r\n        const y = THREE.MathUtils.mapLinear(row, 0, SETTINGS.rows - 1, halfH, -halfH);\r\n\r\n        const stepX = SETTINGS.worldWidth \/ SETTINGS.cols;\r\n        const stepY = SETTINGS.worldHeight \/ SETTINGS.rows;\r\n\r\n        const staggerX = row % 2 === 0 ? 0 : stepX * 0.10;\r\n        const staggerY = col % 2 === 0 ? 0 : stepY * 0.02;\r\n\r\n        anchors.push({\r\n          x: x + staggerX,\r\n          y: y + staggerY\r\n        });\r\n      }\r\n    }\r\n\r\n    return shuffle(anchors);\r\n  }\r\n\r\n  function getJitterForTier(tier) {\r\n    if (SETTINGS.isMobile) {\r\n      if (tier === 'hero') return { x: 0.05, y: 0.05 };\r\n      if (tier === 'medium') return { x: 0.07, y: 0.06 };\r\n      return { x: 0.08, y: 0.07 };\r\n    }\r\n\r\n    if (tier === 'hero') return { x: 0.28, y: 0.22 };\r\n    if (tier === 'medium') return { x: 0.42, y: 0.34 };\r\n    return { x: 0.55, y: 0.42 };\r\n  }\r\n\r\n  function getZForTier(tier) {\r\n    if (SETTINGS.isMobile) {\r\n      if (tier === 'hero') return -Math.random() * 0.08;\r\n      if (tier === 'medium') return -0.03 - Math.random() * 0.12;\r\n      return -0.05 - Math.random() * SETTINGS.depth;\r\n    }\r\n\r\n    if (tier === 'hero') return -Math.random() * 0.45;\r\n    if (tier === 'medium') return -0.2 - Math.random() * 1.0;\r\n    return -0.5 - Math.random() * SETTINGS.depth;\r\n  }\r\n\r\n  function createSprites(items) {\r\n    const loader = new THREE.TextureLoader();\r\n    const maxAnisotropy = renderer.capabilities.getMaxAnisotropy();\r\n    const placed = [];\r\n    const anchors = createAnchorGrid();\r\n    const randomized = shuffle(items);\r\n\r\n    randomized.forEach((item, index) => {\r\n      const tier = getTierForIndex(index);\r\n      const sizeRange = getSizeForTier(tier);\r\n\r\n      const texture = loader.load(item.image, () => {\r\n        texture.colorSpace = THREE.SRGBColorSpace;\r\n        texture.minFilter = THREE.LinearMipmapLinearFilter;\r\n        texture.magFilter = THREE.LinearFilter;\r\n        texture.anisotropy = Math.min(8, maxAnisotropy);\r\n        texture.needsUpdate = true;\r\n      });\r\n\r\n      const material = new THREE.SpriteMaterial({\r\n        map: texture,\r\n        transparent: true,\r\n        opacity: 0,\r\n        depthWrite: false\r\n      });\r\n\r\n      const sprite = new THREE.Sprite(material);\r\n      let finalData = null;\r\n\r\n      const localAnchors = shuffle(anchors);\r\n\r\n      for (let attempt = 0; attempt < SETTINGS.maxPlacementAttempts; attempt++) {\r\n        const anchor = localAnchors[attempt % localAnchors.length];\r\n        const jitter = getJitterForTier(tier);\r\n\r\n        const x = anchor.x + (Math.random() - 0.5) * jitter.x;\r\n        const y = anchor.y + (Math.random() - 0.5) * jitter.y;\r\n        const z = getZForTier(tier);\r\n\r\n        const baseHeight = THREE.MathUtils.lerp(sizeRange.min, sizeRange.max, Math.random());\r\n        const aspect = item.aspect || 1;\r\n        const width = baseHeight * aspect;\r\n        const height = baseHeight;\r\n\r\n        const candidate = { x, y, z, width, height };\r\n\r\n        if (!insideWorld(x, y, width, height)) continue;\r\n        if (isOverlapping(candidate, placed)) continue;\r\n\r\n        placed.push(candidate);\r\n        finalData = { ...candidate, tier };\r\n        break;\r\n      }\r\n\r\n      if (!finalData) return;\r\n\r\n      sprite.position.set(finalData.x, finalData.y, finalData.z);\r\n      sprite.scale.set(finalData.width, finalData.height, 1);\r\n\r\n      sprite.userData = {\r\n        id: item.id,\r\n        title: item.title,\r\n        url: item.url,\r\n        baseX: finalData.x,\r\n        baseY: finalData.y,\r\n        baseZ: finalData.z,\r\n        baseScaleX: finalData.width,\r\n        baseScaleY: finalData.height,\r\n        hoverScaleX: finalData.width * 1.02,\r\n        hoverScaleY: finalData.height * 1.02,\r\n        hovered: false,\r\n        tier: finalData.tier,\r\n        floatOffset: Math.random() * Math.PI * 2,\r\n        floatSpeed: SETTINGS.isMobile\r\n          ? (finalData.tier === 'hero' ? 0.006 + Math.random() * 0.006 : 0.008 + Math.random() * 0.008)\r\n          : (finalData.tier === 'hero' ? 0.020 + Math.random() * 0.020 : 0.028 + Math.random() * 0.030),\r\n        fadeInDelay: Math.random() * 0.8\r\n      };\r\n\r\n      sprites.push(sprite);\r\n      worldGroup.add(sprite);\r\n    });\r\n  }\r\n\r\n  function setPointerFromEvent(clientX, clientY) {\r\n    const rect = container.getBoundingClientRect();\r\n    const x = ((clientX - rect.left) \/ rect.width) * 2 - 1;\r\n    const y = -((clientY - rect.top) \/ rect.height) * 2 + 1;\r\n\r\n    mouse.set(x, y);\r\n    targetMouseX = x;\r\n    targetMouseY = y;\r\n  }\r\n\r\n  function handleRaycast() {\r\n    if (SETTINGS.isMobile) return;\r\n\r\n    raycaster.setFromCamera(mouse, camera);\r\n    const intersects = raycaster.intersectObjects(sprites);\r\n\r\n    if (hoveredSprite && (!intersects.length || hoveredSprite !== intersects[0].object)) {\r\n      hoveredSprite.userData.hovered = false;\r\n      hoveredSprite = null;\r\n      tooltipEl.classList.remove('is-visible');\r\n      container.style.cursor = 'default';\r\n    }\r\n\r\n    if (intersects.length) {\r\n      const sprite = intersects[0].object;\r\n      if (hoveredSprite !== sprite) {\r\n        if (hoveredSprite) hoveredSprite.userData.hovered = false;\r\n        hoveredSprite = sprite;\r\n        hoveredSprite.userData.hovered = true;\r\n        tooltipEl.textContent = hoveredSprite.userData.title;\r\n        tooltipEl.classList.add('is-visible');\r\n        container.style.cursor = 'pointer';\r\n      }\r\n    }\r\n  }\r\n\r\n  function getIntersectedSprite(clientX, clientY) {\r\n    setPointerFromEvent(clientX, clientY);\r\n    raycaster.setFromCamera(mouse, camera);\r\n    const intersects = raycaster.intersectObjects(sprites);\r\n    return intersects.length ? intersects[0].object : null;\r\n  }\r\n\r\n  function updateSprites(time) {\r\n    const t = time * 0.001;\r\n\r\n    sprites.forEach((sprite) => {\r\n      const d = sprite.userData;\r\n      const amp = SETTINGS.isMobile\r\n        ? (d.tier === 'hero' ? 0.0012 : d.tier === 'medium' ? 0.0018 : 0.0022)\r\n        : (d.tier === 'hero' ? 0.010 : d.tier === 'medium' ? 0.014 : 0.018);\r\n\r\n      sprite.position.x = d.baseX + Math.cos(t * d.floatSpeed + d.floatOffset) * amp;\r\n      sprite.position.y = d.baseY + Math.sin(t * d.floatSpeed + d.floatOffset) * amp * 1.08;\r\n      sprite.position.z = d.baseZ + Math.sin(t * d.floatSpeed + d.floatOffset) * amp * 0.18;\r\n\r\n      const depthFactor = THREE.MathUtils.mapLinear(\r\n        sprite.position.z,\r\n        -SETTINGS.depth,\r\n        0,\r\n        0.999,\r\n        1.004\r\n      );\r\n\r\n      const targetScaleX = (d.hovered ? d.hoverScaleX : d.baseScaleX) * depthFactor;\r\n      const targetScaleY = (d.hovered ? d.hoverScaleY : d.baseScaleY) * depthFactor;\r\n\r\n      sprite.scale.x += (targetScaleX - sprite.scale.x) * 0.08;\r\n      sprite.scale.y += (targetScaleY - sprite.scale.y) * 0.08;\r\n\r\n      const fadeTarget = t > d.fadeInDelay ? (d.hovered ? 1 : 0.978) : 0;\r\n      sprite.material.opacity += (fadeTarget - sprite.material.opacity) * 0.06;\r\n    });\r\n  }\r\n\r\n  function updateWorldMotion(time) {\r\n    const t = time * 0.00008;\r\n\r\n    const driftX = Math.sin(t * 1.1) * SETTINGS.driftX + Math.cos(t * 0.63) * SETTINGS.driftX * 0.45;\r\n    const driftY = Math.cos(t * 0.92) * SETTINGS.driftY + Math.sin(t * 0.51) * SETTINGS.driftY * 0.35;\r\n\r\n    currentMouseX += (targetMouseX - currentMouseX) * 0.018;\r\n    currentMouseY += (targetMouseY - currentMouseY) * 0.018;\r\n\r\n    worldGroup.position.x = driftX + currentMouseX * 0.7;\r\n    worldGroup.position.y = driftY + currentMouseY * 0.45;\r\n\r\n    worldGroup.rotation.z = Math.sin(t * 0.7) * SETTINGS.driftRotation;\r\n    worldGroup.rotation.y = SETTINGS.isMobile ? 0 : currentMouseX * 0.01;\r\n    worldGroup.rotation.x = SETTINGS.isMobile ? 0 : currentMouseY * 0.006;\r\n  }\r\n\r\n  function animate(time) {\r\n    animationId = requestAnimationFrame(animate);\r\n    handleRaycast();\r\n    updateSprites(time);\r\n    updateWorldMotion(time);\r\n    renderer.render(scene, camera);\r\n  }\r\n\r\n  function onPointerMove(event) {\r\n    if (SETTINGS.isMobile) return;\r\n\r\n    setPointerFromEvent(event.clientX, event.clientY);\r\n    tooltipEl.style.left = `${event.clientX}px`;\r\n    tooltipEl.style.top = `${event.clientY}px`;\r\n  }\r\n\r\n  function onPointerLeave() {\r\n    mouse.set(-10, -10);\r\n    targetMouseX = 0;\r\n    targetMouseY = 0;\r\n\r\n    if (hoveredSprite) {\r\n      hoveredSprite.userData.hovered = false;\r\n      hoveredSprite = null;\r\n    }\r\n\r\n    tooltipEl.classList.remove('is-visible');\r\n    container.style.cursor = 'default';\r\n  }\r\n\r\n  function onClick(event) {\r\n    if (SETTINGS.isMobile) {\r\n      const touchSprite = getIntersectedSprite(event.clientX, event.clientY);\r\n      if (touchSprite?.userData?.url) {\r\n        window.location.href = touchSprite.userData.url;\r\n      }\r\n      return;\r\n    }\r\n\r\n    if (hoveredSprite?.userData?.url) {\r\n      window.location.href = hoveredSprite.userData.url;\r\n    }\r\n  }\r\n\r\n  function destroyScene() {\r\n    if (animationId) cancelAnimationFrame(animationId);\r\n\r\n    sprites.forEach((sprite) => {\r\n      sprite.material?.map?.dispose?.();\r\n      sprite.material?.dispose?.();\r\n    });\r\n\r\n    sprites = [];\r\n    hoveredSprite = null;\r\n\r\n    if (renderer) {\r\n      renderer.dispose();\r\n      if (renderer.domElement && renderer.domElement.parentNode) {\r\n        renderer.domElement.parentNode.removeChild(renderer.domElement);\r\n      }\r\n    }\r\n\r\n    scene = null;\r\n    camera = null;\r\n    renderer = null;\r\n    raycaster = null;\r\n    worldGroup = null;\r\n  }\r\n\r\n  async function buildScene() {\r\n    SETTINGS = getSettings();\r\n    destroyScene();\r\n    initThree();\r\n\r\n    const rawItems = await fetchAllPortfolioItems();\r\n    const items = normalizeItems(rawItems);\r\n\r\n    if (!items.length) {\r\n      loadingEl.style.display = 'flex';\r\n      loadingEl.textContent = 'No se encontraron im\u00e1genes destacadas en el portfolio.';\r\n      return;\r\n    }\r\n\r\n    const selected = shuffle(items).slice(0, SETTINGS.maxItems);\r\n    createSprites(selected);\r\n\r\n    if (!sprites.length) {\r\n      loadingEl.style.display = 'flex';\r\n      loadingEl.textContent = 'No se pudieron colocar im\u00e1genes.';\r\n      return;\r\n    }\r\n\r\n    loadingEl.style.display = 'none';\r\n    animate(0);\r\n  }\r\n\r\n  function onResize() {\r\n    clearTimeout(resizeTimer);\r\n    resizeTimer = setTimeout(() => {\r\n      buildScene().catch((error) => {\r\n        console.error(error);\r\n        loadingEl.style.display = 'flex';\r\n        loadingEl.textContent = 'Error al reajustar el portfolio.';\r\n      });\r\n    }, 250);\r\n  }\r\n\r\n  async function start() {\r\n    try {\r\n      await buildScene();\r\n    } catch (error) {\r\n      console.error(error);\r\n      loadingEl.textContent = 'Error cargando el portfolio. Revisa el endpoint REST.';\r\n    }\r\n  }\r\n\r\n  container.addEventListener('mousemove', onPointerMove);\r\n  container.addEventListener('mouseleave', onPointerLeave);\r\n  container.addEventListener('click', onClick);\r\n  window.addEventListener('resize', onResize);\r\n\r\n  start();\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>Loading portfolio&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"page-full-width.php","meta":{"footnotes":""},"class_list":["post-8673","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8673","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=8673"}],"version-history":[{"count":122,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8673\/revisions"}],"predecessor-version":[{"id":8970,"href":"https:\/\/kekoph.com\/index.php?rest_route=\/wp\/v2\/pages\/8673\/revisions\/8970"}],"wp:attachment":[{"href":"https:\/\/kekoph.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=8673"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}