From cabe0602df3ca5024efc24873a16840330bac79a Mon Sep 17 00:00:00 2001 From: purankuton Date: Mon, 21 Sep 2026 16:57:35 +0900 Subject: [PATCH 1/3] Add live X trend themes with Jev emotion analysis --- .env.example | 5 ++ WAVES.md | 43 +++++++++++++++- shader/EarthShader.tsx | 20 ++++++-- shader/NoiseShader.tsx | 8 ++- src/components/Earth.tsx | 21 ++++++-- src/components/MessagePannel.tsx | 2 +- src/components/ThemePanel.tsx | 76 ++++++++++++++++++++++++++++ src/components/TweetPannel.tsx | 9 +++- src/context/WebSocket.tsx | 14 +++-- src/lib/server/jsonRequest.ts | 24 +++++++++ src/lib/server/themeService.ts | 67 ++++++++++++++++++++++++ src/lib/themes.ts | 87 ++++++++++++++++++++++++++++++++ src/pages/api/themes/[id].ts | 10 ++++ src/pages/api/themes/index.ts | 9 ++++ src/pages/index.tsx | 2 + tests/theme-service.cjs | 22 ++++++++ tests/themes.cjs | 34 +++++++++++++ 17 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 src/components/ThemePanel.tsx create mode 100644 src/lib/server/jsonRequest.ts create mode 100644 src/lib/server/themeService.ts create mode 100644 src/lib/themes.ts create mode 100644 src/pages/api/themes/[id].ts create mode 100644 src/pages/api/themes/index.ts create mode 100644 tests/theme-service.cjs create mode 100644 tests/themes.cjs diff --git a/.env.example b/.env.example index 595d943..86b72cf 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,8 @@ TYPESAFE_DEFAULT_MODEL=jev-latest # Optional original live feed NEXT_PUBLIC_WEBSOCKET_URL= + +# Server-side live Japanese X trends (15-minute cache) +TREG_TOKEN= +# Required for an identity token; use your own active team slug. +TREG_ORG= diff --git a/WAVES.md b/WAVES.md index 1755fd1..a778b8e 100644 --- a/WAVES.md +++ b/WAVES.md @@ -12,7 +12,8 @@ is pinned in package.json and .nvmrc and verified by the production build. Copy `.env.example` to `.env.local` and set `TYPESAFE_API_KEY` on the server. Without a key, emotion analysis is disabled and POST returns 503. No keyword/demo fallback is included. Upstream errors return 502, with a 12-second request timeout. -Real Jev accuracy and latency have not been verified with a live credential. +Live Jev calls were verified locally for SNS samples and a user-authored wave. +Model accuracy has not been independently evaluated. ## Usage and storage @@ -41,3 +42,43 @@ and never split a blend when capacity is exhausted. `node --test tests/wave-emotion.cjs` `yarn tsc --noEmit` `yarn build` + +## Live themes + +Set `TREG_TOKEN`, optional identity-token `TREG_ORG`, and `TYPESAFE_API_KEY` +in server-side environment variables. Do not prefix these with NEXT_PUBLIC. +The original globe page now offers five Japanese X trend themes, automatically +refreshing while the page is open every 15 minutes. No background scheduler runs +when no one is viewing the site. + +GET `/api/themes` retrieves Japanese trends via TikHub through treg. Category +coverage is preferred over a pure rank list (sports, culture, society, everyday). +Exact hashtag/width/case variants are merged; semantic event alias merging and +inference of which current match is being discussed are not implemented. +GET `/api/themes/:id` accepts only an ID in the current list, retrieves up to 20 +recent search results via AnyAPI, and samples at most 8 posts from the last 24h, +one per author, removing repeated text and IDs. A single Jev batch scores all +five emotions independently for each sampled post. Public responses contain +aggregate scores and source links, not copies of author profiles or post text. + +The aggregate mood gently changes the ambient flow speed, with a four-second +transition. A single user-authored post never changes global flow. + +At present, sports groups use explicit mentions of eight supported national +teams. They do NOT infer nationality, support, or a team's true public mood. +Posts mentioning both/neither remain a separate group. Without two mentioned +teams, the overall five-emotion blend is shown. Named cities in theme titles use the topic location. Tokyo is an explicitly +labelled symbolic origin for unlocated themes. Group links rotate the globe to that group. + +Aggregates animate as a dated visualization until their age reaches 30 minutes; +this is not a live firehose or a count of newly arriving posts. User-authored +waves last 60 seconds and retain their chosen topic and coordinates. Unrelated +local topic posts are hidden when another theme is selected. + +Requests are coalesced with a 15-minute bounded in-process cache, a 1-minute +failure backoff, and stale results explicitly labelled for at most 30 minutes. +Treg uses per-endpoint/query/time-window idempotency keys. Caches are per hosting +instance; multi-instance Jev spend is not globally capped. Configure provider +spending limits for production, and use a shared cache for large deployments. +Observed catalog prices: trends $0.001/call, search ~$0.00075/call; Jev is billed +separately. Loading a theme performs one batch inference, never one per frame. diff --git a/shader/EarthShader.tsx b/shader/EarthShader.tsx index 26cb76b..ca6c2d7 100644 --- a/shader/EarthShader.tsx +++ b/shader/EarthShader.tsx @@ -7,11 +7,13 @@ import {Color, ShaderMaterial, Uniform, Vector2, Vector3, Vector4} from 'three'; import {useTexture} from '@react-three/drei'; import {useWebSocket} from '../src/context/WebSocket'; import {translateGeoCoords} from '../src/utils'; +import {themeFlowSpeed} from '../src/lib/themes'; import {axes, colors, Emotions} from '../src/lib/waveEmotion'; export const EarthShader = () => { const earthTexture = useTexture('earth_light.jpg'); const earthState = useWebSocket(); + const flowSpeed = useRef(1); const shaderRef = useRef(null!); const uniforms = useMemo(() => ({ iResolution: new Uniform(new Vector2(1, 1)), iTime: new Uniform(0), waveTime: new Uniform(0), earthTexture: new Uniform(earthTexture), @@ -24,12 +26,17 @@ export const EarthShader = () => { }), [earthTexture]); useFrame(({size}, delta) => { if (!shaderRef.current) return; - uniforms.iTime.value += delta * .6; + flowSpeed.current += (themeFlowSpeed(earthState.theme) - flowSpeed.current) * (1 - Math.exp(-delta / 4)); + uniforms.iTime.value += delta * .6 * flowSpeed.current; uniforms.waveTime.value += delta * .6; uniforms.iResolution.value.set(size.width, size.height); + const report = earthState.theme && Date.now() - Date.parse(earthState.theme.updatedAt) < 30 * 60 * 1000 ? earthState.theme : null; + const groups = report?.groups.slice(0, 3) || []; + // Keep room for the theme and at least one complete personal emotion blend. + let capacity = 20 - groups.reduce((sum, group) => sum + axes.filter(axis => group.emotions[axis] > 0).length, 0); let index = 0; const put = (center: Vector3, strength: number, age: number, kind = -1, weight = 1) => { - if (index >= 20) return; + if (index >= capacity) return; uniforms.waves.value[index].set(center.x, center.y, center.z, strength); if (kind >= 0) {const c = new Color(colors[axes[kind]]); uniforms.emotionColors.value[index].set(c.r, c.g, c.b, 1);} else uniforms.emotionColors.value[index].set(0, 0, 0, 0); @@ -44,7 +51,7 @@ export const EarthShader = () => { const total = axes.reduce((sum, axis) => sum + emotions[axis], 0); const active = axes.filter(axis => emotions[axis] > 0); // Never drop just one part of a blend when the buffer is full. - if (!strength || index + active.length > 20) return; + if (!strength || index + active.length > capacity) return; active.forEach(axis => put(center, strength, age, axes.indexOf(axis), emotions[axis] / Math.max(1, total))); }; const now = Date.now(); @@ -59,6 +66,13 @@ export const EarthShader = () => { if (tweet.emotions) putBlend(center, tweet.emotions, age); else put(center, Math.max(-1, Math.min(1, tweet.score)), age); }); + // Aggregate groups are an ongoing visualization of a dated sample, not new posts. + capacity = 20; + if (report) { + groups.forEach(group => { + putBlend(translateGeoCoords(group.latitude, group.longitude, 1), group.emotions, 12); + }); + } for (; index < 20; index++) uniforms.waves.value[index].set(0, 0, 0, -2); }); return ; diff --git a/shader/NoiseShader.tsx b/shader/NoiseShader.tsx index 445d4bd..3dd700d 100644 --- a/shader/NoiseShader.tsx +++ b/shader/NoiseShader.tsx @@ -1,10 +1,14 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useRef} from 'react'; import {useFrame} from '@react-three/fiber'; import NoiseFrag from './Noise.frag'; import Fluid from './Fluid.glsl'; import {DoubleSide, Uniform, Vector2} from 'three'; +import {useWebSocket} from '../src/context/WebSocket'; +import {themeFlowSpeed} from '../src/lib/themes'; export const NoiseShader = () => { + const {theme} = useWebSocket(); + const speed = useRef(1); const uniforms = useMemo(() => ({iResolution: new Uniform(new Vector2(1,1)), iTime: new Uniform(0)}), []); - useFrame(({size}, delta) => {uniforms.iResolution.value.set(size.width,size.height); uniforms.iTime.value += delta*.6;}); + useFrame(({size}, delta) => {uniforms.iResolution.value.set(size.width,size.height); speed.current += (themeFlowSpeed(theme) - speed.current)*(1-Math.exp(-delta/4)); uniforms.iTime.value += delta*.6*speed.current;}); return ; }; diff --git a/src/components/Earth.tsx b/src/components/Earth.tsx index 8bf16fa..6e37f69 100644 --- a/src/components/Earth.tsx +++ b/src/components/Earth.tsx @@ -1,13 +1,26 @@ import {EarthProps} from '../../types/util'; -import {useFrame} from '@react-three/fiber'; -import {useRef} from 'react'; +import {useFrame, useThree} from '@react-three/fiber'; +import {useEffect, useRef} from 'react'; import {EarthShader} from '../../shader/EarthShader'; -import {Mesh} from 'three'; +import {Mesh, Vector3} from 'three'; + +import {useWebSocket} from '../context/WebSocket'; +import {translateGeoCoords} from '../utils'; export default function Earth({position, rotation, playing, dispatch}: EarthProps) { const ref = useRef(); + const {theme, focusGroup, focusRevision} = useWebSocket(); + const {camera} = useThree(); + const group = theme?.groups.find(item => item.id === focusGroup) || theme?.groups[0]; + useEffect(() => { + if (!ref.current) return; + if (!group) {ref.current.rotation.set(0, rotation, 0); return;} + const origin = translateGeoCoords(group.latitude, group.longitude, 1).normalize(); + const facing = camera.getWorldPosition(new Vector3()).sub(position).normalize(); + ref.current.quaternion.setFromUnitVectors(origin, facing); + }, [focusRevision, theme?.theme.id, group?.id, group?.latitude, group?.longitude]); useFrame(() => { - if (ref.current && playing) { + if (ref.current && playing && !theme) { const time = new Date(Date.now()); const second = time.getSeconds() + time.getMilliseconds()/1000; dispatch({type: 'SET_ROTATION', payload: 2 * Math.PI * second/60}); diff --git a/src/components/MessagePannel.tsx b/src/components/MessagePannel.tsx index cf6c7fb..d38b3ee 100644 --- a/src/components/MessagePannel.tsx +++ b/src/components/MessagePannel.tsx @@ -52,7 +52,7 @@ export const MessagePannel: VFC = ({modalState}) => {
{tweet.text}
{tweet.emotions &&
-
{tweet.cityName} · Jev 推定 · この画面のみ
+
{tweet.themeTitle && `「${tweet.themeTitle}」 · `}{tweet.cityName} · Jev 推定 · この画面のみ
{axes.map(axis => )} diff --git a/src/components/ThemePanel.tsx b/src/components/ThemePanel.tsx new file mode 100644 index 0000000..edd2552 --- /dev/null +++ b/src/components/ThemePanel.tsx @@ -0,0 +1,76 @@ +import {useEffect, useRef, useState} from 'react'; +import {useRouter} from 'next/router'; +import {useWebSocket} from '../context/WebSocket'; +import {Theme, ThemeList, ThemeReport, THEME_INTERVAL} from '../lib/themes'; +import {axes, colors, labels} from '../lib/waveEmotion'; + +const panel = {position: 'absolute' as const, left: 18, top: 145, width: 245, maxHeight: 'calc(100vh - 235px)', overflowY: 'auto' as const, zIndex: 36, padding: 16, borderRadius: 18, color: '#fff', background: 'rgba(28,24,65,.76)', border: '1px solid #ffffff45', backdropFilter: 'blur(16px)'}; +export function ThemePanel() { + const {theme: report, setTheme, setFocusGroup} = useWebSocket(); + const [list, setList] = useState(null); + const [selected, setSelected] = useState(''); + const [busy, setBusy] = useState(false), [error, setError] = useState(''); + const [collapsed, setCollapsed] = useState(false); + const [clock, setClock] = useState(Date.now()); + const chosen = useRef(''), request = useRef(null); + const live = useRef(true), refresh = useRef<() => void>(() => {}); + const router = useRouter(); + async function loadTheme(theme: Theme, clear = true) { + request.current?.abort(); + const controller = new AbortController(); request.current = controller; + chosen.current = theme.id; setSelected(theme.id); setBusy(true); setError(''); + if (clear) {setTheme(null); setFocusGroup('');} + const timeout = setTimeout(() => controller.abort(), 60000); + try { + const response = await fetch(`/api/themes/${encodeURIComponent(theme.id)}`, {signal: controller.signal}); + const data = await response.json(); + if (!response.ok) throw new Error(data.error); + if (live.current && request.current === controller) setTheme(data as ThemeReport); + } catch (e) { + if (live.current && request.current === controller) setError(controller.signal.aborted ? '読み込みに時間がかかっています。もう一度お試しください。' : e instanceof Error ? e.message : '分析できませんでした。'); + } finally {clearTimeout(timeout); if (live.current && request.current === controller) setBusy(false);} + } + useEffect(() => { + live.current = true; + let listing: AbortController | null = null; + const update = async () => { + listing?.abort(); const controller = new AbortController(); listing = controller; + const timeout = setTimeout(() => controller.abort(), 30000); + try { + const response = await fetch('/api/themes', {signal: controller.signal}); + const data = await response.json(); + if (!response.ok) throw new Error(data.error); + if (!live.current || listing !== controller) return; + setList(data); setError(''); + const next = data.themes.find((item: Theme) => item.id === chosen.current) || data.themes[0]; + if (next) loadTheme(next, next.id !== chosen.current); + } catch (e) {if (live.current && listing === controller) setError('話題を取得できませんでした。少し待ってお試しください。');} + finally {clearTimeout(timeout);} + }; + refresh.current = update; + update(); + const timer = setInterval(update, THEME_INTERVAL); + const clockTimer = setInterval(() => setClock(Date.now()), 30000); + return () => {live.current = false; listing?.abort(); request.current?.abort(); clearInterval(timer); clearInterval(clockTimer);}; + }, []); + const expired = report && clock - Date.parse(report.updatedAt) >= 30 * 60 * 1000; + return ; +} diff --git a/src/components/TweetPannel.tsx b/src/components/TweetPannel.tsx index c2534d1..f0ab114 100644 --- a/src/components/TweetPannel.tsx +++ b/src/components/TweetPannel.tsx @@ -16,6 +16,12 @@ export const TweetPannel: VFC = ({isOpen, onClose}) => { const earth = useWebSocket(); useEffect(() => { if (!isOpen) return; + const origin = earth.theme?.groups.find(group => group.id === earth.focusGroup) || earth.theme?.groups[0]; + if (origin) { + setLocation({latitude: origin.latitude, longitude: origin.longitude}); + const index = cities.findIndex(city => city.lat === origin.latitude && city.lon === origin.longitude); + setCity(index >= 0 ? index : 0); + } let live = true; fetch('/api/wave-emotion').then(response => {if (!response.ok) throw new Error(); return response.json();}).then(result => {if (live) setMode(result.mode);}).catch(() => {if (live) setMode('error');}); return () => {live = false;}; @@ -31,7 +37,7 @@ export const TweetPannel: VFC = ({isOpen, onClose}) => { if (!response.ok) throw new Error(result.error || '判定できませんでした。'); const emotions = result.emotions; const score = Math.max(-1, Math.min(1, (emotions.joy + emotions.empathy)/2 - (emotions.sadness + emotions.anger + emotions.anxiety)/3)); - earth.addLocalTweet({_id: `local-${Date.now()}-${Math.random().toString(36).slice(2)}`, text: text.trim(), time: new Date().toISOString(), loc: [location.latitude, location.longitude], cityName: location.latitude === cities[city].lat && location.longitude === cities[city].lon ? cities[city].name : `${location.latitude}°, ${location.longitude}°`, score, emotions, source: result.source, account: {id: 0, name: 'あなたの波', screenName: '', profileImage: '/assets/images/favicon.png'}}); + earth.addLocalTweet({_id: `local-${Date.now()}-${Math.random().toString(36).slice(2)}`, text: text.trim(), time: new Date().toISOString(), loc: [location.latitude, location.longitude], cityName: location.latitude === cities[city].lat && location.longitude === cities[city].lon ? cities[city].name : `${location.latitude}°, ${location.longitude}°`, score, emotions, themeId: earth.theme?.theme.id, themeTitle: earth.theme?.theme.title, source: result.source, account: {id: 0, name: 'あなたの波', screenName: '', profileImage: '/assets/images/favicon.png'}}); setText(''); onClose(); } catch (e) {setError(e instanceof Error && e.name !== 'AbortError' ? e.message : '接続できませんでした。もう一度お試しください。');} finally {clearTimeout(timeout); pending.current = false; setBusy(false);} @@ -49,6 +55,7 @@ export const TweetPannel: VFC = ({isOpen, onClose}) => { 言葉で世界に波を起こそう! 嬉しかったこと、悲しかったこと。混ざった気持ちを、そのまま地球に広げてみましょう。 + {earth.theme && 「{earth.theme.theme.title}」にあなたの波を重ねます} あなたの言葉