Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
66 changes: 66 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ yarn-error.log*

# typescript
*.tsbuildinfo

# Cloudflare
.wrangler/
.next-cloudflare/
.dev.vars*
71 changes: 71 additions & 0 deletions CLOUDFLARE.md
Original file line number Diff line number Diff line change
@@ -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).
190 changes: 185 additions & 5 deletions WAVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
4 changes: 4 additions & 0 deletions cloudflare/next-headers.ts
Original file line number Diff line number Diff line change
@@ -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');}
Loading
Loading