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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..082008f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Test and deploy to Cloudflare + +# Pull requests: tests, type check and a Cloudflare build with a Workers dry run. +# Pushes to main (and manual runs): the same checks, then `wrangler deploy` to meta-earth-wave.com. +# Runtime secrets (TREG_TOKEN, TREG_ORG, TYPESAFE_API_KEY, NEXTAUTH_SECRET) live on the Worker and survive deploys. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'deploy-production' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + WRANGLER_VERSION: 4.129.1 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install + run: yarn install --frozen-lockfile + + - name: Test + run: node --test tests/*.cjs + + - name: Type check + run: yarn -s tsc --noEmit + + - name: Build static assets for Workers + run: yarn build:cloudflare + + - name: Workers dry run + run: npx --yes wrangler@${WRANGLER_VERSION} deploy --dry-run + + - name: Deploy to Cloudflare + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: ${{ env.WRANGLER_VERSION }} + command: deploy --message "${{ github.sha }}" + + - name: Smoke test production + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + run: | + build_id=$(cat .next-cloudflare/BUILD_ID) + for i in $(seq 1 10); do + if curl -fsS https://meta-earth-wave.com/ | grep -q "/_next/static/${build_id}/"; then echo "meta-earth-wave.com serves build ${build_id}"; break; fi + [ "$i" = 10 ] && { echo "::error::meta-earth-wave.com is not serving build ${build_id}"; exit 1; } + sleep 6 + done + curl -fsS -o /dev/null -w "GET /api/themes -> %{http_code}\n" https://meta-earth-wave.com/api/themes diff --git a/.gitignore b/.gitignore index 7993147..0d7421d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ yarn-error.log* # typescript *.tsbuildinfo + +# Cloudflare +.wrangler/ +.next-cloudflare/ +.dev.vars* diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md new file mode 100644 index 0000000..bb845be --- /dev/null +++ b/CLOUDFLARE.md @@ -0,0 +1,71 @@ +# Cloudflare deployment + +URL: https://meta-earth-wave.com + +Fallback: https://metaearthwave.watanabe-koto.workers.dev + +The apex custom domain is managed by the `routes` entry in `wrangler.jsonc`. + +The existing Next.js 12 frontend is statically exported; `cloudflare/worker.ts` +adapts the existing Pages API handlers to Workers requests and responses. +No Next.js server or SSR endpoint is exposed. API handlers, including NextAuth, +run with Node compatibility. `next/headers` is stubbed only for NextAuth's unused +App Router branch. Pages Router requests always pass explicit request/response objects. + +## CI/CD + +`.github/workflows/deploy.yml` runs on every pull request and on pushes to `main`: +tests, type check, `yarn build:cloudflare` and `wrangler deploy --dry-run`. +On `main` (push or manual "Run workflow") it then runs `wrangler deploy` and checks that +meta-earth-wave.com serves the new build ID and that `/api/themes` answers. + +Repository settings it needs: + +- Secret `CLOUDFLARE_API_TOKEN`: a Cloudflare API token from the "Edit Cloudflare Workers" template +- Variable `CLOUDFLARE_ACCOUNT_ID`: the Cloudflare account ID (not secret) + +Worker runtime secrets are managed with `wrangler secret put` as below; CI never sees them. +To roll back, use `wrangler rollback` or pick an earlier version in the Cloudflare dashboard. + +## Build and publish (manual) + +Use Node 22 (see package.json engines) and Wrangler 4.129.1 or later: + +```sh +npm run build:cloudflare +wrangler deploy --dry-run +wrangler deploy +``` + +The build uses `.next-cloudflare` so the ordinary local `.next` build remains separate. +Images are served directly. The unused archival `public/earth.jpg` (28 MB) +is excluded via `out/.assetsignore`; the actual earth texture stays in the deployment. + +Set server-only secrets using `wrangler secret put NAME`: + +- TYPESAFE_API_KEY: Jev analysis +- TREG_TOKEN, TREG_ORG: trend/post retrieval +- NEXTAUTH_SECRET: session signing +- TWITTER_CLIENT_ID, TWITTER_CLIENT_SECRET: optional X login +- NEXTAUTH_URL: set to the public origin when configuring X login + +Never use NEXT_PUBLIC_* for authentication secrets. +The initial deployment has Jev/treg credentials and NEXTAUTH_SECRET. +X credentials and a posting WebSocket server were not present locally, so X login/posting +is not configured. Geolocation requires the visitor's browser permission. +Individual waves remain local to the visitor's browser, as before deployment. + +## Verification on 2026-09-25 + +Production build and export, Workers dry run, and 24 existing tests passed. +The deployed site renders the globe. Live Jev input and trend availability were +checked separately; a deployment does not replenish the external treg balance. +Theme caches are per Worker isolate, not a durable shared cache. + +## Refresh interval + +Trend refresh, server cache TTL and treg accepted cache age are 6 hours. +Existing trend waves remain visible for up to two refresh periods (12 hours) +so they do not disappear before the next refresh. Refresh remains visitor-driven, +not a centralized scheduled job; the interval is not an account-wide spend cap. +Personal input preview remains real-time (minimum request start spacing 800 ms). diff --git a/WAVES.md b/WAVES.md index 7a0a601..c4e0ca0 100644 --- a/WAVES.md +++ b/WAVES.md @@ -12,12 +12,16 @@ 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 -START → 波を生み出す → text and location → 感情の波を生み出す. -The location defaults to Tokyo; users can choose a city or enter latitude/longitude. +START → 波を生み出す → text → browser location permission → 感情の波を生み出す. +User-authored waves use browser geolocation at submission time. No city or coordinate +selector is shown. A denied, unavailable, or timed-out location blocks submission +with a retryable message; no default location is silently substituted. Geolocation +is requested before paid analysis, with a 10-second timeout and a 60-second cache. The message panel displays the five estimated strengths without test sliders. Jev posts live in this browser session only (20 maximum); reload clears them. @@ -33,7 +37,7 @@ and empathy mixes gently. The five strengths blend with normalized weights. Colored waves remain within radius 0.16 on the unit sphere; weaker displacement travels outward at 0.065 units/second and fades by radius 0.36. A post does not change the entire globe or background. Posts expire after 60 seconds, fading -between seconds 42 and 60. The 20 available visual layers prioritize newer posts +between seconds 42 and 60. The 40 available visual layers reserve one blended layer per mapped region and prioritize newer personal posts and never split a blend when capacity is exhausted. ## Wave captions @@ -57,6 +61,182 @@ Planner: `src/lib/waveCaption.ts`, renderer: `src/lib/waveCaptionDraw.ts`, overl ## Validation -`node --test tests/wave-emotion.cjs tests/wave-caption.cjs` +`node --test tests/*.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. It follows up to +12 AnyAPI search pages (requested limit 50, actual provider page size may be lower), +aiming for 120 recent posts in the last 24h, one per author, deduplicating text and +IDs. Fewer than 100 posts is explicitly marked as a limited sample. Search is no +longer restricted to Japanese, but Japanese trend queries still bias the sample; +these results do not represent entire countries or the world. + +Batches of 10 posts (at most 3 in flight) score five emotions and infer a broad +posting region using text, language and public profile location when returned by +the search provider. Missing profile fields are left empty; no extra profile +lookup runs. The current atlas supports 24 countries and eight Japanese regions. +It does not infer nationality or exact personal coordinates. News locations, +travel destinations and team names are not sufficient posting-location evidence. +Chosen-region probability must reach .70; language-only assignments require .85 +and are restricted to coarse Japanese, Korean and Thai locations. These model +probabilities are not calibrated geolocation accuracy. Other ambiguous languages, +unsupported regions and insufficient evidence remain unknown and are not mapped. + +Each mapped region averages its own five emotions, blending color and movement +at its display centroid with a country/region sized footprint. Centroids are +visual anchors, not measured origins. No unknown posts fall back to Tokyo. Group +buttons rotate the globe to that region. Public responses contain aggregate scores, +counts, inference notes and source links, not author profiles or post text. Details +show the actual sample size and unknown count. Browser geolocation remains the +source for the user's own submitted wave. + +The aggregate mood gently changes ambient flow speed over four seconds, only +when at least three mapped samples exist. A single user-authored post never +changes global flow. Local region counts can still be small even with 120 total +posts, and should not be interpreted as population opinion. + +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 browser-provided 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. A theme performs up to 12 batch inferences, never one per frame. Search costs +at most approximately $0.009 per theme, plus separately billed inference. +The first analysis is a blocking request with a four-minute client timeout. +Production hosts with short request limits need a background job and shared +result storage before this larger analysis can be deployed reliably. + +## Globe-first X discovery (current UI) + +The permanent theme selector is replaced with clickable waves on the existing globe. +`/api/themes` assigns a broad topic-related location through a bounded inference +of the title/category. This is not posting-origin inference. If confidence is below +.85 or inference fails, it uses the explicitly labelled Japan trend observation +anchor. This does not claim a real event origin. The source feed remains Japan; +no multi-country collector was added after the interaction design changed. + +Waves at the same anchor are grouped; clicking opens a compact list of X search +links. A single-topic wave opens its X search directly. Every link encodes the +original title (including hashtags), opens a new tab with noopener/noreferrer, +and leaves the globe available. Waves stop after 30 minutes without a fresh list +and disappear on the next list update when no longer selected. Selection covers +five category-diverse topics, not every trend. + +Emotion analysis runs for the five visible topics with two client jobs at a time; +search remains bounded at 12 calls per topic (about $0.045 total maximum for five), +plus inference. The neutral discovery wave is available immediately, then receives +the overall topic's emotion blend after analysis. Co-located topics share a mixed +wave and have individual emotion-colored dots in their link list. Errors leave a +neutral wave and never manufacture scores. Trend rank and sample size are not +presented as measured discussion volume. Provider fees and caches above still apply. + +## Global floating keywords (supersedes globe-first regional anchors) + +The current discovery view collects the top five trends from eight markets: +United States, United Kingdom, Brazil, India, France, South Africa, Australia and +Japan. Failed markets are disclosed, and successful coverage is shown in the UI. +This is an eight-market sample, not complete worldwide coverage or a post count. +Exact normalized aliases merge first. A bounded choice evaluation merges other +same-event aliases only at probability >= .90; related-but-different events must +stay separate. If that service fails only exact aliases merge. + +For each event, the best rank per country contributes 1/sqrt(rank). Contributions +sum across countries; the top twelve events become individual floating keywords. +Search includes collected aliases joined by OR without a Japanese-language filter. +Keyword placement is a visual layout, never a claimed origin. Font size uses this +cross-market prominence plus emotion intensity. Text gradients preserve all five +emotion weights; a neutral white word indicates analysis is not yet available. +Clicking each keyword opens X directly, without grouping topics into a region list. +Emotion analysis still targets up to 120 posts per event, with two events in flight. +At twelve events the search ceiling is approximately $0.108 per refresh, plus +$0.008 for trend feeds and separately billed analysis/alias matching. This sample +can still be biased by source coverage, aliases and languages returned by search. + +If alias inference returns HTTP 401/402/403, the list explicitly reports analysis +unavailable and no post searches are launched. The visible words remain neutral; +exact-name aggregation and rank-based sizing continue. A live verification on +2026-09-21 retrieved all eight countries but the inference provider returned 402, +so live emotion color/size verification is blocked until that account is usable. + +## Full-sphere coverage update + +Discovery now samples sixteen markets, adding Mexico, Argentina, Nigeria, Kenya, +Indonesia, South Korea, Germany and Turkey to the previous eight. All sixteen +feeds succeeded in local verification. Feed cost ceiling is about $0.016/update; +twelve-topic post search limits remain unchanged. This is still sampled coverage, +not every country in the world. + +Keywords use an equal-area golden-angle distribution across the entire globe, +covering both hemispheres and all longitude quadrants. They are no longer confined +to the front/Asian hemisphere. Orbit controls slowly auto-rotate while the keyword +view is active; users can drag to see the other side. Layout remains non-geographic. + +## Trend details before external navigation + +Wave and keyword clicks now open an accessible modal on the existing page. Only +its explicit `Xで見る` link opens the external search in a new tab. Escape, the +close button and `地球に戻る` dismiss the modal; auto-rotation pauses while open. +The modal follows refreshed analysis results, showing five independent 0–100 +strengths, actual sample count, participating markets, rank, aliases and timestamp. +Provider-supplied descriptions appear as the overview when available; missing +summaries and missing scores are labelled rather than fabricated. Pending, +unavailable and failed analysis states are distinguished. +Live verification on 2026-09-22 displayed five emotion values from 120 actual +posts for #Brownlow; the previous billing failure was not present in this check. + +## Unified emotion analysis (2026-09-22) + +Personal waves and each SNS post now use `server/analyzeEmotions.ts`: one trimmed +`state.utterance`, the same five questions, decoder, model setting and 12-second +upstream timeout. Empathy means the author's expressed understanding of and care +for another person's feelings, not the reader's reaction to the author's distress. +Region questions remain in separate batches and never share the emotion request. +SNS analysis retains three workers, the 120-post target and the existing TTL cache. +At 120 posts this uses 120 emotion requests plus 12 region requests instead of 12 +combined requests; latency and request volume increase. Synthetic regression +checks do not establish accuracy on real SNS data. Quote attribution and ambiguous +short text still need independent evaluation. + +## Live personal-wave preview + +The existing text modal previews feelings while typing, including IME text changes. +Request starts are throttled to at least 800 ms apart, with only one request in flight. +Completed snapshots update the wave while newer text is queued. The last wave stays +visible during updates; clearing text invalidates outstanding snapshots. Cached +newer results cannot be overwritten by an older in-flight result. Closing the modal cancels the request +and clears the 20-entry in-memory cache. The UI explains that draft text is sent +for analysis before submission; previews neither request location nor post to X. +The preview blends five coloured moving wave layers with smooth intensity changes +and honours reduced-motion preferences. Submission reuses a completed result only +when its trimmed text exactly matches, otherwise it analyses the submitted text. +Location permission is still required to place the final wave on the globe. + +## Topic overview and observed-country anchors + +When a feed has no description, `summarizeTheme` asks Jev Choice to select explanatory +sentences from up to 120 fetched posts. This is an extractive overview, not generated +prose or fact verification. The modal labels excerpts and links to their source posts. +Invalid or low-confidence selections produce no overview; summary failures do not +fail emotion analysis. Reports carry the enriched theme to the globe and its modal. +Waves now anchor near the observed country with the best local trend rank (ties use +country-name order), with a small visual offset. Multiple countries still form one +global topic. The location is an observation point, not a claim about an event's origin. diff --git a/cloudflare/next-headers.ts b/cloudflare/next-headers.ts new file mode 100644 index 0000000..a30c92b --- /dev/null +++ b/cloudflare/next-headers.ts @@ -0,0 +1,4 @@ +// NextAuth's unused App Router branch imports this on Next.js 12. +// Workers call its Pages API handler with explicit request/response objects. +export function headers(): never {throw new Error('App Router headers are not used by this Pages Router application');} +export function cookies(): never {throw new Error('App Router cookies are not used by this Pages Router application');} diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts new file mode 100644 index 0000000..177bab4 --- /dev/null +++ b/cloudflare/worker.ts @@ -0,0 +1,51 @@ +import waveEmotion from '../src/pages/api/wave-emotion'; +import themes from '../src/pages/api/themes'; +import theme from '../src/pages/api/themes/[id]'; +import auth from '../src/pages/api/auth/[...nextauth]'; +import restricted from '../src/pages/api/restricted'; + +type Env = {ASSETS: {fetch(request: Request): Promise}}; +// Adapt the existing Pages API handlers without shipping the Next.js server. +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith('/api/')) return env.ASSETS.fetch(request); + const headers = new Headers({'Cache-Control': 'no-store'}); + const jsonError = (status: number, error: string) => new Response(JSON.stringify({error}), {status, headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}}); + const query: Record = Object.fromEntries(url.searchParams); + let handler: any; + if (url.pathname === '/api/wave-emotion') handler = waveEmotion; + else if (url.pathname === '/api/themes') handler = themes; + else if (/^\/api\/themes\/[^/]+$/.test(url.pathname)) { + try {query.id = decodeURIComponent(url.pathname.slice('/api/themes/'.length));} catch {return jsonError(400, 'Invalid topic');} + handler = theme; + } else if (url.pathname.startsWith('/api/auth/')) { + query.nextauth = url.pathname.slice('/api/auth/'.length).split('/'); handler = auth; + } else if (url.pathname === '/api/restricted') handler = restricted; + else return jsonError(404, 'Not found'); + if (!['GET', 'POST', 'HEAD'].includes(request.method)) return jsonError(405, 'Method not allowed'); + if (request.method === 'POST' && request.headers.get('Origin') && request.headers.get('Origin') !== url.origin) return jsonError(403, 'Invalid origin'); + let body: any; + if (request.body) { + const reader = request.body.getReader(); let size = 0; const parts: Uint8Array[] = []; + while (true) {const part = await reader.read(); if (part.done) break; size += part.value.length; if (size > 4096) {await reader.cancel(); return jsonError(413, 'Request too large');} parts.push(part.value);} + const bytes = new Uint8Array(size); let offset = 0; for (const part of parts) {bytes.set(part, offset); offset += part.length;} + const text = new TextDecoder().decode(bytes); + try {body = request.headers.get('Content-Type')?.includes('application/x-www-form-urlencoded') ? Object.fromEntries(new URLSearchParams(text)) : text ? JSON.parse(text) : undefined;} catch {return jsonError(400, 'Invalid request');} + } + const cookies: Record = {}; + for (const part of (request.headers.get('Cookie') || '').split(';')) {const i = part.indexOf('='); if (i > 0) {try {cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1));} catch {}}} + let status = 200, result: any = null; + const req = {method: request.method, body, query, cookies, headers: {...Object.fromEntries(request.headers), 'x-forwarded-host': url.host, 'x-forwarded-proto': url.protocol.slice(0, -1)}, url: url.pathname + url.search}; + const res: any = { + status(code: number) {status = code; return res;}, + getHeader(name: string) {return name.toLowerCase() === 'set-cookie' ? (headers as Headers & {getSetCookie(): string[]}).getSetCookie() : headers.get(name);}, + setHeader(name: string, value: string | string[]) {headers.delete(name); for (const item of Array.isArray(value) ? value : [value]) headers.append(name, String(item)); return res;}, + json(value: unknown) {headers.set('Content-Type', 'application/json; charset=utf-8'); result = JSON.stringify(value); return res;}, + send(value: unknown) {if (typeof value === 'object' && value !== null) return res.json(value); result = value; return res;}, + end(value?: unknown) {if (value !== undefined) result = value; return res;}, + }; + try {await handler(req, res); return new Response(request.method === 'HEAD' ? null : result, {status, headers});} + catch {return jsonError(500, '処理できませんでした。しばらく待ってお試しください。');} + }, +}; diff --git a/next.config.js b/next.config.js index 264ef59..8ba739a 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,8 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + ...(process.env.CLOUDFLARE_BUILD ? {distDir: ".next-cloudflare", experimental: {cpus: 2}} : {}), images: { + ...(process.env.CLOUDFLARE_BUILD ? {loader: 'custom'} : {}), domains: ['pbs.twimg.com'], }, eslint: { diff --git a/package.json b/package.json index e25ed42..8397ff6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "build:cloudflare": "CLOUDFLARE_BUILD=1 next build && CLOUDFLARE_BUILD=1 next export && node scripts/cloudflare-assets.cjs", + "deploy:cloudflare": "npm run build:cloudflare && wrangler deploy" }, "dependencies": { "@chakra-ui/react": "^1.8.9", diff --git a/scripts/cloudflare-assets.cjs b/scripts/cloudflare-assets.cjs new file mode 100644 index 0000000..fc5d645 --- /dev/null +++ b/scripts/cloudflare-assets.cjs @@ -0,0 +1,4 @@ +const fs = require('node:fs'); +// Unused archival 28 MB texture exceeds Workers' 25 MiB asset limit. +// Keep the source image; exclude only this unused file from deployment. +fs.writeFileSync('out/.assetsignore', '/earth.jpg\n'); diff --git a/shader/Earth.frag b/shader/Earth.frag index effc639..17c087a 100644 --- a/shader/Earth.frag +++ b/shader/Earth.frag @@ -3,22 +3,24 @@ varying vec2 vUv; uniform float iTime; uniform float waveTime; uniform sampler2D earthTexture; -uniform vec4 waves[20]; -uniform vec4 emotionColors[20]; -uniform float waveKinds[20]; -uniform float waveAges[20]; -uniform float waveTravelAges[20]; -uniform float waveWeights[20]; +uniform vec4 waves[40]; +uniform vec4 emotionColors[40]; +uniform float waveRadii[40]; +uniform vec4 waveBlend[40]; +uniform float waveKinds[40]; +uniform float waveAges[40]; +uniform float waveTravelAges[40]; +uniform float waveWeights[40]; void main() { vec3 p = normalize(vPosition); vec3 displacement = vec3(0.); vec3 pigment = vec3(0.); float density = 0.; - for (int i=0; i<20; i++) { + for (int i=0; i<40; i++) { if (waves[i].a != -2.) { float strength = abs(waves[i].a); - float radius = .04 + strength*.12; + float radius = waveRadii[i] > 0. ? waveRadii[i] : .04 + strength*.12; vec3 center = normalize(waves[i].xyz); float distanceToOrigin = length(p-center); // A softer wake travels beyond the colored source, never across the whole globe. @@ -69,6 +71,16 @@ void main() { } else { current = vec2(-q.y,q.x)*.85 + vec2(sin(q.y*2.+t*.45),cos(q.x*2.-t*.45))*.35; } + if (kind > 4.5) { + vec4 blend = waveBlend[i]; + float empathy = max(0.,1.-dot(blend,vec4(1.))); + vec2 swirl = vec2(-q.y,q.x); + current = blend.x*vec2(sin(q.y*4.-t*1.1)*.6,.65+.25*cos(q.x*3.+t)) + + blend.y*vec2(sin(q.y*3.+t*.3)*.18,-.7) + + blend.z*(swirl*1.7+vec2(sin(q.y*8.+t*1.5),cos(q.x*7.-t*1.2))*.45) + + blend.w*(swirl*.6+vec2(sin(q.y*11.+t*1.7),cos(q.x*13.-t*1.3))*.35) + + empathy*(swirl*.85+vec2(sin(q.y*2.+t*.45),cos(q.x*2.-t*.45))*.35); + } // Push the shared flow outwards at the advancing front; curl its wake. current = outward*(crest*1.3-wake*.25) + current*(.25+wake*.55); displacement += (east*current.x+north*current.y)*mask*.11; diff --git a/shader/EarthShader.tsx b/shader/EarthShader.tsx index 26cb76b..0577c2a 100644 --- a/shader/EarthShader.tsx +++ b/shader/EarthShader.tsx @@ -7,32 +7,43 @@ 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), - waves: new Uniform(Array.from({length: 20}, () => new Vector4(0, 0, 0, -2))), - emotionColors: new Uniform(Array.from({length: 20}, () => new Vector4(0, 0, 0, 0))), - waveKinds: new Uniform(Array(20).fill(-1)), - waveAges: new Uniform(Array(20).fill(0)), - waveTravelAges: new Uniform(Array(20).fill(0)), - waveWeights: new Uniform(Array(20).fill(1)), + waves: new Uniform(Array.from({length: 40}, () => new Vector4(0, 0, 0, -2))), + emotionColors: new Uniform(Array.from({length: 40}, () => new Vector4(0, 0, 0, 0))), + waveRadii: new Uniform(Array(40).fill(0)), + waveBlend: new Uniform(Array.from({length: 40}, () => new Vector4(0,0,0,0))), + waveKinds: new Uniform(Array(40).fill(-1)), + waveAges: new Uniform(Array(40).fill(0)), + waveTravelAges: new Uniform(Array(40).fill(0)), + waveWeights: new Uniform(Array(40).fill(1)), }), [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, 32) || []; + // Keep room for the theme and at least one complete personal emotion blend. + let capacity = 40 - groups.length; 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);} + if (kind >= 0 && kind < 5) {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); + uniforms.waveRadii.value[index] = 0; + uniforms.waveBlend.value[index].set(0,0,0,0); uniforms.waveKinds.value[index] = kind; uniforms.waveAges.value[index] = age; uniforms.waveTravelAges.value[index] = age; @@ -44,7 +55,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,7 +70,24 @@ export const EarthShader = () => { if (tweet.emotions) putBlend(center, tweet.emotions, age); else put(center, Math.max(-1, Math.min(1, tweet.score)), age); }); - for (; index < 20; index++) uniforms.waves.value[index].set(0, 0, 0, -2); + // Aggregate groups are an ongoing visualization of a dated sample, not new posts. + capacity = 40; + if (report) { + groups.forEach(group => { + const center = translateGeoCoords(group.latitude, group.longitude, 1); + const total = axes.reduce((sum, axis) => sum + group.emotions[axis], 0); + if (total <= 0 || index >= 40) return; + const slot = index; + const strength = Math.max(...axes.map(axis => group.emotions[axis])); + put(center, strength, 12, 5); + const color = new Color(0,0,0); + axes.forEach(axis => color.add(new Color(colors[axis]).multiplyScalar(group.emotions[axis]/total))); + uniforms.emotionColors.value[slot].set(color.r,color.g,color.b,1); + uniforms.waveRadii.value[slot] = group.radius; + uniforms.waveBlend.value[slot].set(group.emotions.joy/total,group.emotions.sadness/total,group.emotions.anger/total,group.emotions.anxiety/total); + }); + } + for (; index < 40; 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/AuthorCard.tsx b/src/components/AuthorCard.tsx index 7234d18..0c57694 100644 --- a/src/components/AuthorCard.tsx +++ b/src/components/AuthorCard.tsx @@ -12,7 +12,7 @@ export const AuthorCard: VFC = const {width, height} = useWindowSize(); return (
- {image && diff --git a/src/components/Earth.tsx b/src/components/Earth.tsx index 8bf16fa..1cff94e 100644 --- a/src/components/Earth.tsx +++ b/src/components/Earth.tsx @@ -1,13 +1,27 @@ +import {TrendWaves} from './TrendWaves'; 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, trendWaves} = useWebSocket(); + const {camera} = useThree(); + const group = theme?.groups.find(item => item.id === focusGroup) || theme?.groups[0] || (trendWaves[0] ? {latitude:15,longitude:0,id:'global-keywords'} : undefined); + 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 && !trendWaves.length) { const time = new Date(Date.now()); const second = time.getSeconds() + time.getMilliseconds()/1000; dispatch({type: 'SET_ROTATION', payload: 2 * Math.PI * second/60}); @@ -20,6 +34,7 @@ export default function Earth({position, rotation, playing, dispatch}: EarthProp + ); diff --git a/src/components/EmotionPreview.tsx b/src/components/EmotionPreview.tsx new file mode 100644 index 0000000..43d41c8 --- /dev/null +++ b/src/components/EmotionPreview.tsx @@ -0,0 +1,47 @@ +import React, {useEffect, useRef} from 'react'; +import {Box, Text} from '@chakra-ui/react'; +import {axes, colors, Emotions, labels} from '../lib/waveEmotion'; +import {PreviewState} from '../lib/emotionPreview'; +export function EmotionPreview({state}: {state: PreviewState}) { + const canvas = useRef(null); + const target = useRef(state.emotions); + useEffect(() => {target.current = state.emotions;}, [state.emotions]); + useEffect(() => { + const el = canvas.current; if (!el) return; + const ctx = el.getContext('2d'); if (!ctx) return; + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const values = axes.map(() => 0); + let frame = 0, previous = 0, time = 0; + const draw = (now: number) => { + const dt = previous ? Math.min((now - previous)/1000, .05) : .016; previous = now; + if (!reduced) time += dt; + const w = el.clientWidth, h = 120, ratio = Math.min(window.devicePixelRatio || 1, 2); + if (el.width !== Math.round(w*ratio) || el.height !== h*ratio) {el.width = Math.round(w*ratio); el.height = h*ratio;} + ctx.setTransform(ratio,0,0,ratio,0,0); ctx.clearRect(0,0,w,h); + axes.forEach((axis,i) => {values[i] += ((target.current?.[axis] || 0)-values[i])*(1-Math.exp(-dt*4));}); + const strength = Math.max(...values); + ctx.globalCompositeOperation = 'screen'; + for (let i = 0; i < 5; i++) { + ctx.strokeStyle = colors[axes[i]]; ctx.globalAlpha = .06 + values[i]*.7; ctx.lineWidth = 1.5+values[i]*2; + for (let band = 0; band < 3; band++) { + ctx.beginPath(); + for (let x = 0; x <= w; x += 3) { + const envelope = Math.sin(x/w*Math.PI); + const y = 60+Math.sin(x/w*9-time*(1+i*.12)+i*.8+band*.45)*envelope*(8+strength*24+band*4); + if (!x) ctx.moveTo(x,y); else ctx.lineTo(x,y); + } + ctx.stroke(); + } + } + ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; + frame = requestAnimationFrame(draw); + }; + frame = requestAnimationFrame(draw); return () => cancelAnimationFrame(frame); + }, []); + const message = state.status === 'error' ? '波を読み取れませんでした。送信時にもう一度試します。' : ''; + return + ; +} diff --git a/src/components/MessagePannel.tsx b/src/components/MessagePannel.tsx index cf6c7fb..7971f67 100644 --- a/src/components/MessagePannel.tsx +++ b/src/components/MessagePannel.tsx @@ -41,7 +41,7 @@ export const MessagePannel: VFC = ({modalState}) => { // console.log(playing); }}>
- = ({modalState}) => {
{tweet.text}
{tweet.emotions &&
-
{tweet.cityName} · Jev 推定 · この画面のみ
+
{tweet.themeTitle && `「${tweet.themeTitle}」 · `}{tweet.cityName} · この画面のみ
{axes.map(axis => )} diff --git a/src/components/TCanvas.tsx b/src/components/TCanvas.tsx index 03d1af1..a4b891d 100644 --- a/src/components/TCanvas.tsx +++ b/src/components/TCanvas.tsx @@ -26,6 +26,8 @@ export const TCanvas: VFC = () => { 0 && !earthState.theme && !earthState.selectedTrend} + autoRotateSpeed={.65} enableZoom={false} enablePan={false}/> diff --git a/src/components/ThemePanel.tsx b/src/components/ThemePanel.tsx new file mode 100644 index 0000000..609f40f --- /dev/null +++ b/src/components/ThemePanel.tsx @@ -0,0 +1,58 @@ +import {marketLabels} from '../lib/globalTrends'; +import {useEffect, useState} from 'react'; +import {useWebSocket} from '../context/WebSocket'; +import {ThemeList, THEME_INTERVAL} from '../lib/themes'; +import {observedTrendOrigin, TrendWave} from '../lib/trendWaves'; + +// Discovery stays on the globe. X links do not wait for post analysis. +export function ThemePanel() { + const {setTrendWaves, setTheme} = useWebSocket(); + const [countries,setCountries]=useState([]); + const [error,setError]=useState(''),[loading,setLoading]=useState(true),[revision,setRevision]=useState(0); + useEffect(()=>{ + let live=true; let controller:AbortController|undefined; + setTheme(null); + async function refresh() { + setLoading(true); setError(''); + controller?.abort(); controller=new AbortController(); const current=controller; + const timeout=setTimeout(()=>current.abort(),65000); + try { + const response=await fetch('/api/themes',{signal:current.signal}); + const data:ThemeList & {code?:string;error?:string}=await response.json(); + if(!response.ok) throw new Error(data.code === 'TREND_CREDITS_EXHAUSTED' ? data.error : '話題を更新できませんでした。'); + if(live && current===controller) { + const waves:TrendWave[]=data.themes.flatMap(theme=>{const origin=observedTrendOrigin(theme);return origin?[{theme,origin,updatedAt:data.updatedAt,analysisStatus:data.analysisAvailable===false?"unavailable":"pending" as const}]:[];}); + setTrendWaves(waves);setCountries(data.countries||[]); + // Two analyses at a time; discovery and links are immediately usable. + let offset=0; + const analyze=async()=>{ + while(live && current===controller && offset{live=false;controller?.abort();clearInterval(timer);}; + },[revision]); + return ; +} diff --git a/src/components/TrendModal.tsx b/src/components/TrendModal.tsx new file mode 100644 index 0000000..cd709dd --- /dev/null +++ b/src/components/TrendModal.tsx @@ -0,0 +1,48 @@ +import {Box,Button,Modal,ModalBody,ModalCloseButton,ModalContent,ModalFooter,ModalHeader,ModalOverlay,Text} from '@chakra-ui/react'; +import {useWebSocket} from '../context/WebSocket'; +import {axes,colors,labels} from '../lib/waveEmotion'; +import {marketLabels} from '../lib/globalTrends'; +import {trendSearchUrl} from '../lib/trendWaves'; + +export function TrendModal(){ + const {selectedTrend,setSelectedTrend,trendWaves}=useWebSocket(); + const wave=trendWaves.find(item=>item.theme.id===selectedTrend?.theme.id)||selectedTrend; + const close=()=>setSelectedTrend(null); + if(!wave)return null; + const {theme,emotions}=wave; + const countries=(theme.sourceCountries||[]).map(c=>marketLabels[c]||c); + const stale=Date.now()-Date.parse(wave.updatedAt)>=30*60*1000; + const status=wave.analysisStatus==='pending'?'感情を分析しています。結果が届くとここに表示されます。':wave.analysisStatus==='error'?'感情分析を取得できませんでした。次の更新で再試行します。':'感情分析は現在利用できません。未分析の値は表示していません。'; + return + + + {theme.title} + + {theme.summaryKind === 'excerpts' ? '概要(投稿から抜粋)' : '概要'} + {theme.summary || (wave.analysisStatus === 'pending' ? '関連投稿から概要を確認しています…' : '概要に使える情報を取得できませんでした。関連投稿は「Xで見る」から確認できます。')} + {theme.summaryKind === 'excerpts' && + 取得した投稿からの抜粋です。投稿の主張を事実と確認したものではありません。 + {(theme.summarySources || []).filter(url => /^https:\/\/x\.com\/i\/web\/status\/\d+$/.test(url)).map((url,i)=>)} + } + 取得した各国のXトレンドを合算した話題です。{countries.length}か国で登場し、今回の集計では{theme.rank}位です。 + {countries.join('・')} + 波の位置:{wave.origin.label}。複数国では最も順位が高い観測国を代表にしています。出来事の発生地ではありません。 + 国ごとの順位と登場国数から集計しています。投稿総数や世界全体の順位を示すものではありません。 + {theme.aliases.length>1 && 関連する表記:{theme.aliases.join(' / ')}} + 感情パラメーター + {!emotions && {status}} + + {axes.map(axis=>{const value=emotions?Math.round(emotions[axis]*100):null;return + {labels[axis]}{value===null?'未分析':`${value} / 100`} + + {value!==null && } + + ;})} + + {emotions && {wave.sampleCount??'—'}件の投稿から推定。5つの感情は独立した強さで、合計100にはなりません。} + {stale?'過去の取得結果 · ':''}話題の取得 {new Date(wave.updatedAt).toLocaleString('ja-JP')} + + + + ; +} diff --git a/src/components/TrendWaves.tsx b/src/components/TrendWaves.tsx new file mode 100644 index 0000000..3485a4b --- /dev/null +++ b/src/components/TrendWaves.tsx @@ -0,0 +1,61 @@ +import {axes, colors} from '../lib/waveEmotion'; +import {useMemo, useRef, useState} from 'react'; +import {Html} from '@react-three/drei'; +import {useFrame} from '@react-three/fiber'; +import {Color, Quaternion, ShaderMaterial, Vector3} from 'three'; +import {useWebSocket} from '../context/WebSocket'; +import {activeTrendWaves, TrendWave, keywordSize} from '../lib/trendWaves'; +import {translateGeoCoords} from '../utils'; +import Fluid from '../../shader/Fluid.glsl'; + +const vertex=`varying vec2 uvWave; void main(){uvWave=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`; +const fragment=Fluid+` +varying vec2 uvWave; uniform float time; uniform vec3 tint; uniform float analyzed; +void main(){ + vec2 p=uvWave*2.-1.;float r=length(p); + float warp=(fluidNoise(vec3(p*4.,time*.3))-.5)*.12; + float crest=pow(.5+.5*cos((r+warp)*24.-time*3.),5.); + float edge=(1.-smoothstep(.6,1.,r))*smoothstep(0.,.10,r); + vec3 color=mix(vec3(.85,.92,1.),tint,analyzed); + gl_FragColor=vec4(color,edge*(.12+crest*.72)); +}`; +function SourceWave({wave}:{wave:TrendWave}) { + const {setSelectedTrend}=useWebSocket(); + const items=[wave]; const material=useRef(null!); + const origin=items[0].origin; + const center=useMemo(()=>translateGeoCoords(origin.latitude,origin.longitude,1.012),[origin.latitude,origin.longitude]); + const orientation=useMemo(()=>new Quaternion().setFromUnitVectors(new Vector3(0,0,1),center.clone().normalize()),[center]); + const uniforms=useMemo(()=>({time:{value:0},tint:{value:new Color('white')},analyzed:{value:0}}),[]); + const analyzed=items.filter(item=>item.emotions); + const tint=new Color(0,0,0);let weight=0; + analyzed.forEach(item=>axes.forEach(axis=>{const strength=item.emotions![axis];tint.add(new Color(colors[axis]).multiplyScalar(strength));weight+=strength;})); + if(weight)tint.multiplyScalar(1/weight); + uniforms.tint.value.copy(tint);uniforms.analyzed.value=weight?1:0; + useFrame((_,delta)=>{if(material.current)material.current.uniforms.time.value+=delta;}); + const activate=()=>setSelectedTrend(wave); + const size=keywordSize(wave.theme,wave.emotions); + let stop=0;const stops=axes.flatMap(axis=>{const start=stop;stop+=weight?wave.emotions![axis]/weight*100:20;return [`${colors[axis]} ${start}%`,`${colors[axis]} ${stop}%`];}); + const gradient=`linear-gradient(110deg,${stops.join(',')})`; + const strongest=wave.emotions?axes.reduce((a,b)=>wave.emotions![a]>=wave.emotions![b]?a:b):null; + const note=`${wave.theme.sourceCountries?.length||1}か国で話題 · 集計 ${wave.theme.rank}位`; + return + {event.stopPropagation();if(event.delta<5)activate();}}> + + + + + + + ; +} +export function TrendWaves() { + const {trendWaves}=useWebSocket();const [clock,setClock]=useState(Date.now());const elapsed=useRef(0); + useFrame((_,delta)=>{elapsed.current+=delta;if(elapsed.current>1){elapsed.current=0;setClock(Date.now());}}); + return <>{activeTrendWaves(trendWaves,clock).map(wave=>)}; +} diff --git a/src/components/TweetPannel.tsx b/src/components/TweetPannel.tsx index c2534d1..141f543 100644 --- a/src/components/TweetPannel.tsx +++ b/src/components/TweetPannel.tsx @@ -1,17 +1,26 @@ import React, {useEffect, useRef, useState, VFC} from 'react'; -import {Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Textarea, Select, FormLabel, Text} from '@chakra-ui/react'; +import {Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Textarea, Text} from '@chakra-ui/react'; import {signIn, useSession} from 'next-auth/react'; import {useWebSocket} from '../context/WebSocket'; -import {WaveCoordinates} from './WaveCoordinates'; -import {cities} from '../lib/waveEmotion'; +import {EmotionPreview} from './EmotionPreview'; +import {createEmotionPreview, PreviewState, requestEmotions} from '../lib/emotionPreview'; +import {currentLocation} from '../lib/currentLocation'; type Props = {isOpen: boolean; onOpen: () => void; onClose: () => void}; export const TweetPannel: VFC = ({isOpen, onClose}) => { - const [text, setText] = useState(''), [city, setCity] = useState(0); - const [location, setLocation] = useState({latitude: cities[0].lat, longitude: cities[0].lon}); + const [text, setText] = useState(''); + const [progress, setProgress] = useState(''); const [busy, setBusy] = useState(false), [error, setError] = useState(''); const [mode, setMode] = useState<'jev' | 'unavailable' | 'loading' | 'error'>('loading'); const pending = useRef(false); + const [preview, setPreview] = useState({text: '', status: 'idle'}); + const previewController = useRef>(); + useEffect(() => { + if (!isOpen || mode !== 'jev' || busy) return; + const controller = createEmotionPreview(setPreview); previewController.current = controller; + return () => {controller.dispose(); previewController.current = undefined;}; + }, [isOpen, mode, busy]); + useEffect(() => {previewController.current?.update(text);}, [text, isOpen, mode, busy]); const {data} = useSession(); const earth = useWebSocket(); useEffect(() => { @@ -22,46 +31,54 @@ export const TweetPannel: VFC = ({isOpen, onClose}) => { }, [isOpen]); async function createWave() { if (!text.trim() || pending.current) return; + const submittedText = text.trim(); + const cachedEmotions = preview.status === 'ready' && preview.text === submittedText ? preview.emotions : undefined; pending.current = true; setBusy(true); setError(''); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15000); + let timeout: ReturnType | undefined; try { - const response = await fetch('/api/wave-emotion', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: text.trim()}), signal: controller.signal}); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || '判定できませんでした。'); - const emotions = result.emotions; + setProgress('現在地を取得中'); + const location = await currentLocation(); + setProgress('判定中'); + timeout = setTimeout(() => controller.abort(), 15000); + const emotions = cachedEmotions || await requestEmotions(submittedText, controller.signal); 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: '現在地', score, emotions, themeId: earth.theme?.theme.id, themeTitle: earth.theme?.theme.title, source: 'jev', 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);} + finally {if (timeout) clearTimeout(timeout); pending.current = false; setBusy(false); setProgress('');} } - function sendTweet() { + async function sendTweet() { + if (!text.trim() || pending.current) return; if (!earth.socket || earth.socket.readyState !== 1) {setError('Twitter投稿サーバーに接続していません。'); return;} const account = (data?.user as any)?.token?.account; if (!account) {signIn('twitter', {callbackUrl: '/?tweetBox=true'}); return;} - earth.socket.send(JSON.stringify({tweetText: text.trim(), name: data?.user?.name, image: data?.user?.image, accessToken: account.oauth_token, accessTokenSecret: account.oauth_token_secret, loc: [location.latitude, location.longitude]})); - setText(''); onClose(); + pending.current = true; setBusy(true); setError(''); setProgress('現在地を取得中'); + try { + const location = await currentLocation(); + if (earth.socket.readyState !== 1) throw new Error('Twitter投稿サーバーとの接続が切れました。もう一度お試しください。'); + earth.socket.send(JSON.stringify({tweetText: text.trim(), name: data?.user?.name, image: data?.user?.image, accessToken: account.oauth_token, accessTokenSecret: account.oauth_token_secret, loc: [location.latitude, location.longitude]})); + setText(''); onClose(); + } catch (e) {setError(e instanceof Error ? e.message : '投稿できませんでした。');} + finally {pending.current = false; setBusy(false); setProgress('');} } - return {} : onClose} isCentered> + return {} : onClose} isCentered scrollBehavior="inside"> - 言葉で世界に波を起こそう! + いま、どんな気持ち? - 嬉しかったこと、悲しかったこと。混ざった気持ちを、そのまま地球に広げてみましょう。 - あなたの言葉 -