Spaces:
Paused
Paused
Shereen Lee commited on
Commit ·
241eca4
1
Parent(s): 11fc1c9
fix: smoother Cesium globe (on-demand render) + Polaroid-framed photo pins
Browse files- 3D globe: enable requestRenderMode (+MSAA, camera inertia) to fix choppy
rendering when idle; journey flythrough toggles continuous render while it runs.
- 3D globe: replace bare square billboards + caption labels with larger
Polaroid-framed photo pins (white frame, soft shadow) composed on a canvas.
- 2D map: ease-in-out camera easing on journey flyTo/fitBounds for smoother travel.
- docs: add product-overview video script.
- docs/video_script.md +225 -0
- index.html +116 -29
docs/video_script.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sozai — Product Overview Video
|
| 2 |
+
|
| 3 |
+
A walkthrough script and narrative guide for a short product overview of Sozai.
|
| 4 |
+
|
| 5 |
+
**Format:** ~2.5–3 minutes. Mostly **voiceover** over screen recordings and a few
|
| 6 |
+
PowerPoint-style slides. **Two narrators (A and B) taking turns** — conversational,
|
| 7 |
+
warm, informative. Not a dramatized film; think "two people who built this giving
|
| 8 |
+
you the tour." Keep it relaxed and clear.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## The one-sentence pitch
|
| 13 |
+
|
| 14 |
+
> Sozai turns the photos buried in your camera roll into watercolour memories you develop with your companions: like a shared, modern darkroom.
|
| 15 |
+
|
| 16 |
+
**What the name means:** 素材 (*sozai*) is Japanese for "raw material / ingredients."
|
| 17 |
+
Your snapshots are the raw material; the memory is what you make from them. Worth
|
| 18 |
+
saying once in the video — it frames everything.
|
| 19 |
+
|
| 20 |
+
**The core metaphor** the whole product runs on: **analog film photography.** You
|
| 21 |
+
don't "post," you *develop*. There's a real darkroom, chemical trays, prints on a
|
| 22 |
+
line, and you shake each one to reveal it. That metaphor is the brand — lean on it.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## The two narrators
|
| 27 |
+
|
| 28 |
+
- **A** — frames the "why" and the feeling (the camera-roll problem, the togetherness).
|
| 29 |
+
- **B** — drives the "how" and the features (rooms, develop, views, the AI parts).
|
| 30 |
+
|
| 31 |
+
They trade off naturally. Lines are written to be read plainly, not performed.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## The script
|
| 36 |
+
|
| 37 |
+
Each beat = **[slide or screen cue]** + the spoken lines. Screen names match the
|
| 38 |
+
real app so you can record straight from it.
|
| 39 |
+
|
| 40 |
+
### 1. Open — the problem (slide)
|
| 41 |
+
|
| 42 |
+
**[SLIDE: a dense grid of phone photo thumbnails, greyed out. Sozai bird logo in corner.]**
|
| 43 |
+
|
| 44 |
+
> **A:** Most of us take hundreds of photos on a trip — and then never look at them
|
| 45 |
+
> again. They just sit in the camera roll.
|
| 46 |
+
|
| 47 |
+
> **B:** That's the problem we kept running into. So we built Sozai — a place to
|
| 48 |
+
> actually *do something* with those photos, with the people you took them with.
|
| 49 |
+
|
| 50 |
+
**[SLIDE: the word "素材 — raw material" fades in under the Sozai logo.]**
|
| 51 |
+
|
| 52 |
+
> **A:** "Sozai" means "raw material." Your photos are the raw material. The memory
|
| 53 |
+
> is what you make out of them.
|
| 54 |
+
|
| 55 |
+
### 2. Getting in — rooms (screen: landing page)
|
| 56 |
+
|
| 57 |
+
**[SCREEN: the Sozai landing page — bird, map lines, "build memories together".]**
|
| 58 |
+
|
| 59 |
+
> **B:** When you open Sozai, you can start a room and invite someone, join a room
|
| 60 |
+
> with a short code, or just go solo.
|
| 61 |
+
|
| 62 |
+
**[SCREEN: click "make a room" → a short share code appears. Cut to a second
|
| 63 |
+
window: someone types the code into "enter room code" and clicks join. Two named
|
| 64 |
+
cursors now move on the same screen.]**
|
| 65 |
+
|
| 66 |
+
> **A:** Here's the part we love — it's live. Two people, two cities, same room.
|
| 67 |
+
> You can see each other's cursors and edits as they happen.
|
| 68 |
+
|
| 69 |
+
> **B:** Or pick "continue without a room" and it's just you. Same app, no invite
|
| 70 |
+
> needed.
|
| 71 |
+
|
| 72 |
+
### 3. Adding photos + captions (screen: Develop Photos)
|
| 73 |
+
|
| 74 |
+
**[SCREEN: the "Develop Photos" page — photo stack on the left, Title / Caption /
|
| 75 |
+
When & Where on the right.]**
|
| 76 |
+
|
| 77 |
+
> **B:** You drop in your photos — up to eight at a time — and give them a title
|
| 78 |
+
> and a caption.
|
| 79 |
+
|
| 80 |
+
**[SCREEN: click the mic icon on the caption field; speak; text appears.]**
|
| 81 |
+
|
| 82 |
+
> **A:** You can type, or just talk. The mic transcribes what you say, so captioning
|
| 83 |
+
> a photo feels more like telling someone about it.
|
| 84 |
+
|
| 85 |
+
**[SCREEN: click "Auto-generate"; the stages tick by: "Reading the light… Finding
|
| 86 |
+
the words…"; a caption fills in.]**
|
| 87 |
+
|
| 88 |
+
> **B:** Or let the AI take a look and suggest one. It's actually looking at the
|
| 89 |
+
> photo — so the title, caption, and tags match what's really in the picture.
|
| 90 |
+
|
| 91 |
+
### 4. Choosing how it renders (screen: Views grid)
|
| 92 |
+
|
| 93 |
+
**[SCREEN: the "Views" picker — 2D Map, 3D Globe, Canvas, Scrapbook, Gallery,
|
| 94 |
+
Timeline.]**
|
| 95 |
+
|
| 96 |
+
> **A:** Before you develop, you choose how your memories will be laid out — on a
|
| 97 |
+
> map, a spinning globe, a scrapbook, a gallery, or a timeline.
|
| 98 |
+
|
| 99 |
+
> **B:** And this isn't a one-time choice — you can switch any time. Hold that
|
| 100 |
+
> thought, because it's the part we care about most.
|
| 101 |
+
|
| 102 |
+
### 5. The darkroom — the heart (screen: Darkroom)
|
| 103 |
+
|
| 104 |
+
**[SCREEN: click "Develop Photos →". The Darkroom: four glowing trays —
|
| 105 |
+
Developer, Stop Bath, Fixer, Wash. Status line: "giving your photos an acid rinse…"]**
|
| 106 |
+
|
| 107 |
+
> **B:** When you hit develop, your photos go through a darkroom. This is where each
|
| 108 |
+
> one is painted into a watercolour.
|
| 109 |
+
|
| 110 |
+
**[SCREEN: the prints hang on a line. Status: "shake each print to reveal it." A
|
| 111 |
+
blank print reads "developed — shake me"; it's shaken and the watercolour appears.]**
|
| 112 |
+
|
| 113 |
+
> **A:** And then you reveal them — you literally shake each print until the picture
|
| 114 |
+
> comes through. It's a small thing, but it makes you slow down and actually *look*.
|
| 115 |
+
|
| 116 |
+
**[SCREEN: optional — show one original photo next to its finished watercolour.]**
|
| 117 |
+
|
| 118 |
+
> **B:** Same photo, now a painting. That's the "develop."
|
| 119 |
+
|
| 120 |
+
### 6. The payoff — one memory, many ways (screen: 2D Map, then cycle)
|
| 121 |
+
|
| 122 |
+
*This is the most important beat in the video. The core feature isn't any single
|
| 123 |
+
view — it's that the **same memories can be reimagined in many ways**, interactively.
|
| 124 |
+
Anchor on the **2D Map** (it's the most stable to record), then briefly cycle the rest.*
|
| 125 |
+
|
| 126 |
+
**[SCREEN: developed watercolours pinned on the 2D Map — the stable hero shot.
|
| 127 |
+
Pan and zoom around the pins; drag a polaroid to reposition it.]**
|
| 128 |
+
|
| 129 |
+
> **A:** Once they're developed, your memories land in the view you picked. Here
|
| 130 |
+
> they're pinned on the map, right where you took them — and you can move them
|
| 131 |
+
> around, zoom in, make it yours.
|
| 132 |
+
|
| 133 |
+
**[SCREEN: switch views and briefly cycle — 3D Globe → Scrapbook scroll → Timeline
|
| 134 |
+
→ Gallery — the same photos re-laid-out each time, a beat on each.]**
|
| 135 |
+
|
| 136 |
+
> **B:** And this is really the heart of Sozai: the *same* memories, reimagined as
|
| 137 |
+
> many ways as you like. Spin them on a globe, scroll them as a scrapbook, walk them
|
| 138 |
+
> down a timeline, browse them as a gallery — one click each.
|
| 139 |
+
|
| 140 |
+
> **A:** Nothing's locked in. You keep rearranging until it feels like yours.
|
| 141 |
+
|
| 142 |
+
### 7. Together + close
|
| 143 |
+
|
| 144 |
+
**[SCREEN: the share code is sent to a third person; a new named cursor joins the room.]**
|
| 145 |
+
|
| 146 |
+
> **A:** And because it's a room, you can hand the code to someone else and keep
|
| 147 |
+
> building it together — even after the trip's over.
|
| 148 |
+
|
| 149 |
+
**[SLIDE: Sozai logo + "develop your memories together" + "素材 — the raw material of memory".]**
|
| 150 |
+
|
| 151 |
+
> **B:** That's Sozai. Bring a few photos back to life — together.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## Optional add-ons (for a longer / more technical cut)
|
| 156 |
+
|
| 157 |
+
- **Under the hood slide.** One slide for a technical audience: photos are painted
|
| 158 |
+
by FLUX.2-klein with a watercolour style, captions come from the MiniCPM-V vision
|
| 159 |
+
model, voice is transcribed by NVIDIA NeMo, and every uploaded photo passes a
|
| 160 |
+
safety check first.
|
| 161 |
+
- **LLM Trace panel.** Sozai has a built-in trace view (Phoenix) showing every model
|
| 162 |
+
call, its latency, and tokens — good for a "yes, it's real and observable" beat.
|
| 163 |
+
- **The hidden pet.** A little sprite that follows your cursor around the scrapbook —
|
| 164 |
+
a nice "did you catch that?" detail if the tone allows.
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Who it's for (personas — for framing, not necessarily on screen)
|
| 169 |
+
|
| 170 |
+
- **Two friends after a trip** *(the hero use case; our sample photos are a Toronto
|
| 171 |
+
trip).* They develop the trip together from two cities.
|
| 172 |
+
- **Family across distance.** One shared album spanning continents — the globe view
|
| 173 |
+
shines here.
|
| 174 |
+
- **The solo journaler.** "Continue without a room" — a quiet afternoon turning last
|
| 175 |
+
month's photos into a watercolour scrapbook.
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## High-leverage copy recommendations
|
| 180 |
+
|
| 181 |
+
Small wording changes that tighten the story. Each is **current → suggested**.
|
| 182 |
+
Listed by impact. (Not yet applied — review first.)
|
| 183 |
+
|
| 184 |
+
1. **Add a one-line "what is this" under the landing tagline.** Today the landing
|
| 185 |
+
shows only `Sozai / build memories together` with no explanation.
|
| 186 |
+
→ add: **"Turn your camera roll into watercolour memories — together."**
|
| 187 |
+
|
| 188 |
+
2. **Make "develop" the single signature verb.** The app already lives on it
|
| 189 |
+
(Develop Photos, The Darkroom, developing…). Consider the tagline:
|
| 190 |
+
`build memories together` → **"develop your memories together"** (*develop* = both
|
| 191 |
+
*film* and *grow*).
|
| 192 |
+
|
| 193 |
+
3. **Pay off the name once, in-app.** Most people don't know 素材.
|
| 194 |
+
→ add somewhere quiet: **"Sozai (素材) — the raw material of memory."**
|
| 195 |
+
|
| 196 |
+
4. **Sharpen the reveal line.** `developed — shake me`
|
| 197 |
+
→ **"still wet — give it a shake."**
|
| 198 |
+
|
| 199 |
+
5. **Warm up the empty state.** Wherever a room/scrapbook starts empty:
|
| 200 |
+
→ **"Nothing developed yet. Drop in a few photos and let's bring them back."**
|
| 201 |
+
|
| 202 |
+
6. **Keep the verb on the solo path.** `CONTINUE WITHOUT A ROOM →`
|
| 203 |
+
→ **"develop on your own →."**
|
| 204 |
+
|
| 205 |
+
7. **Give the full view a closing line.** On the globe/scrapbook once populated:
|
| 206 |
+
→ **"Every pin is a place you've been."**
|
| 207 |
+
|
| 208 |
+
> Leave the existing playful copy as-is — *"giving your photos an acid rinse,"*
|
| 209 |
+
> *"Reading the light… / Letting it soak… / Finding the words…"*, and the tray
|
| 210 |
+
> names. That voice is the brand; the changes above just extend it.
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
## Maps
|
| 215 |
+
|
| 216 |
+
**Decided:** the **2D Map is the anchor** for the §6 payoff (most stable to record).
|
| 217 |
+
The 3D Globe and the other views are shown as a quick cycle right after, to make the
|
| 218 |
+
point that the same memories can be reimagined many ways — the interactivity is the
|
| 219 |
+
core feature, not any single view.
|
| 220 |
+
|
| 221 |
+
Still to finalize when map footage arrives:
|
| 222 |
+
|
| 223 |
+
- the recording pace of the cycle (how long to hold each view),
|
| 224 |
+
- whether the pin-drop / drag-a-polaroid-on-the-map interaction gets its own spoken
|
| 225 |
+
line or just plays under A's "make it yours" line.
|
index.html
CHANGED
|
@@ -4485,10 +4485,13 @@
|
|
| 4485 |
} catch (e) {}
|
| 4486 |
|
| 4487 |
const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
|
|
|
|
|
|
|
|
|
|
| 4488 |
// start at the first stop
|
| 4489 |
setFlyIdx(0);
|
| 4490 |
setTransportAt(flyStops[0].lon, flyStops[0].lat);
|
| 4491 |
-
map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration:
|
| 4492 |
await new Promise((r) => setTimeout(r, 1500));
|
| 4493 |
if (!token.stop) await new Promise((r) => setTimeout(r, 1200)); // dwell on first photo
|
| 4494 |
|
|
@@ -4514,13 +4517,13 @@
|
|
| 4514 |
});
|
| 4515 |
if (token.stop) break;
|
| 4516 |
setFlyIdx(i);
|
| 4517 |
-
map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration:
|
| 4518 |
-
await new Promise((r) => setTimeout(r,
|
| 4519 |
}
|
| 4520 |
|
| 4521 |
if (!token.stop && flyStops.length > 1) {
|
| 4522 |
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
|
| 4523 |
-
try { map.fitBounds([[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], { padding: 90, pitch: 0, bearing: 0, duration:
|
| 4524 |
await new Promise((r) => setTimeout(r, 2100));
|
| 4525 |
}
|
| 4526 |
// tidy up
|
|
@@ -4800,6 +4803,59 @@
|
|
| 4800 |
// Pinned album photos appear as billboards and the same timestamp-ordered
|
| 4801 |
// flythrough plays here too. Requires a Cesium ion token (server-provided).
|
| 4802 |
// ----------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4803 |
function buildCesiumStyle(Cesium, kind, colorway) {
|
| 4804 |
if (kind === "realistic") {
|
| 4805 |
// Solid, light buildings (no transparency) so sun lighting can model
|
|
@@ -4959,9 +5015,23 @@
|
|
| 4959 |
baseLayerPicker: false, geocoder: false, homeButton: false,
|
| 4960 |
sceneModePicker: false, navigationHelpButton: false, animation: false,
|
| 4961 |
timeline: false, fullscreenButton: false, infoBox: false, selectionIndicator: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4962 |
});
|
| 4963 |
viewerRef.current = viewer;
|
| 4964 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4965 |
viewer.scene.camera.setView({
|
| 4966 |
destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
|
| 4967 |
orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
|
|
@@ -5006,6 +5076,9 @@
|
|
| 5006 |
tilesetRef.current = tileset;
|
| 5007 |
tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
|
| 5008 |
} catch (e) { console.warn("[cesium] OSM buildings failed", e); }
|
|
|
|
|
|
|
|
|
|
| 5009 |
if (!cancelled) setStatus("ready");
|
| 5010 |
} catch (e) {
|
| 5011 |
if (!cancelled) { setStatus("error"); setErrMsg(e.message || "Cesium failed to load."); }
|
|
@@ -5058,38 +5131,45 @@
|
|
| 5058 |
})();
|
| 5059 |
}
|
| 5060 |
prevStyleRef.current = style;
|
|
|
|
|
|
|
| 5061 |
}, [style, colorway, status]);
|
| 5062 |
|
| 5063 |
-
// render pinned photos as billboards
|
|
|
|
|
|
|
| 5064 |
useEffect(() => {
|
| 5065 |
const viewer = viewerRef.current;
|
| 5066 |
if (!viewer || !window.Cesium || status !== "ready") return;
|
| 5067 |
const Cesium = window.Cesium;
|
|
|
|
| 5068 |
entitiesRef.current.forEach((e) => { try { viewer.entities.remove(e); } catch (_) {} });
|
| 5069 |
entitiesRef.current = [];
|
| 5070 |
-
|
| 5071 |
-
const
|
| 5072 |
-
|
| 5073 |
-
|
| 5074 |
-
|
| 5075 |
-
image
|
| 5076 |
-
|
| 5077 |
-
|
| 5078 |
-
|
| 5079 |
-
|
| 5080 |
-
|
| 5081 |
-
|
| 5082 |
-
|
| 5083 |
-
|
| 5084 |
-
|
| 5085 |
-
|
| 5086 |
-
|
| 5087 |
-
|
| 5088 |
-
}
|
| 5089 |
-
|
| 5090 |
-
|
| 5091 |
-
|
| 5092 |
-
|
|
|
|
|
|
|
| 5093 |
|
| 5094 |
// tap-to-pin + drag-to-move on the globe.
|
| 5095 |
const armedRef = useRef(null);
|
|
@@ -5116,7 +5196,7 @@
|
|
| 5116 |
handler.setInputAction((movement) => {
|
| 5117 |
if (!dragId) return;
|
| 5118 |
const cart = viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid);
|
| 5119 |
-
if (cart) { const { lon, lat } = toLngLat(cart); const ent = viewer.entities.getById("sozai-" + dragId); if (ent) ent.position = Cesium.Cartesian3.fromDegrees(lon, lat, 40); }
|
| 5120 |
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
| 5121 |
// LEFT_UP: commit a drag, OR pin an armed photo at the click point
|
| 5122 |
handler.setInputAction((click) => {
|
|
@@ -5167,6 +5247,8 @@
|
|
| 5167 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
| 5168 |
if (clockTickRef.current) { try { clockTickRef.current(); } catch (e) {} clockTickRef.current = null; }
|
| 5169 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
|
|
|
|
|
|
| 5170 |
}
|
| 5171 |
removeVehicle();
|
| 5172 |
}
|
|
@@ -5177,6 +5259,9 @@
|
|
| 5177 |
flyAbortRef.current = { stop: false };
|
| 5178 |
const token = flyAbortRef.current;
|
| 5179 |
setFlying(true);
|
|
|
|
|
|
|
|
|
|
| 5180 |
|
| 5181 |
const tdef = CS_TRANSPORTS.find((t) => t.id === transport) || CS_TRANSPORTS[0];
|
| 5182 |
const isPlane = tdef.id === "plane";
|
|
@@ -5301,6 +5386,8 @@
|
|
| 5301 |
}
|
| 5302 |
removeVehicle();
|
| 5303 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
|
|
|
|
|
|
| 5304 |
setFlying(false); setFlyIdx(-1);
|
| 5305 |
}
|
| 5306 |
|
|
|
|
| 4485 |
} catch (e) {}
|
| 4486 |
|
| 4487 |
const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
|
| 4488 |
+
// gentle ease-in-out so every camera move accelerates and settles
|
| 4489 |
+
// smoothly instead of starting/stopping abruptly (smoother travel).
|
| 4490 |
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
| 4491 |
// start at the first stop
|
| 4492 |
setFlyIdx(0);
|
| 4493 |
setTransportAt(flyStops[0].lon, flyStops[0].lat);
|
| 4494 |
+
map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1600, curve: 1.42, easing: ease, essential: true });
|
| 4495 |
await new Promise((r) => setTimeout(r, 1500));
|
| 4496 |
if (!token.stop) await new Promise((r) => setTimeout(r, 1200)); // dwell on first photo
|
| 4497 |
|
|
|
|
| 4517 |
});
|
| 4518 |
if (token.stop) break;
|
| 4519 |
setFlyIdx(i);
|
| 4520 |
+
map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1300, curve: 1.42, easing: ease, essential: true });
|
| 4521 |
+
await new Promise((r) => setTimeout(r, 1400)); // dwell to show the photo
|
| 4522 |
}
|
| 4523 |
|
| 4524 |
if (!token.stop && flyStops.length > 1) {
|
| 4525 |
const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
|
| 4526 |
+
try { map.fitBounds([[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]], { padding: 90, pitch: 0, bearing: 0, duration: 2200, easing: ease }); } catch (e) {}
|
| 4527 |
await new Promise((r) => setTimeout(r, 2100));
|
| 4528 |
}
|
| 4529 |
// tidy up
|
|
|
|
| 4803 |
// Pinned album photos appear as billboards and the same timestamp-ordered
|
| 4804 |
// flythrough plays here too. Requires a Cesium ion token (server-provided).
|
| 4805 |
// ----------------------------------------------------------------- //
|
| 4806 |
+
|
| 4807 |
+
// Compose a photo into a small Polaroid-style card on a <canvas> so it can
|
| 4808 |
+
// be used directly as a Cesium billboard image: white frame, a heavier
|
| 4809 |
+
// bottom margin (the classic Polaroid look), and a soft drop shadow. No
|
| 4810 |
+
// caption text - just the framed photo. Returns a Promise<HTMLCanvasElement>.
|
| 4811 |
+
function makePolaroidCanvas(src, opts) {
|
| 4812 |
+
opts = opts || {};
|
| 4813 |
+
const photo = opts.photo || 168; // inner photo square (device px)
|
| 4814 |
+
const pad = opts.pad || 14; // white border on top/left/right
|
| 4815 |
+
const bottom = opts.bottom || 40; // heavier bottom border
|
| 4816 |
+
const margin = opts.margin || 12; // breathing room for the shadow
|
| 4817 |
+
return new Promise((resolve, reject) => {
|
| 4818 |
+
const img = new Image();
|
| 4819 |
+
img.crossOrigin = "anonymous";
|
| 4820 |
+
img.onload = () => {
|
| 4821 |
+
try {
|
| 4822 |
+
const w = photo + pad * 2, h = photo + pad + bottom;
|
| 4823 |
+
const cw = w + margin * 2, ch = h + margin * 2;
|
| 4824 |
+
const cv = document.createElement("canvas");
|
| 4825 |
+
cv.width = cw; cv.height = ch;
|
| 4826 |
+
const ctx = cv.getContext("2d");
|
| 4827 |
+
// soft shadow under the card
|
| 4828 |
+
ctx.save();
|
| 4829 |
+
ctx.shadowColor = "rgba(0,0,0,0.38)";
|
| 4830 |
+
ctx.shadowBlur = 9; ctx.shadowOffsetY = 5;
|
| 4831 |
+
ctx.fillStyle = "#fdfdf7"; // warm white card
|
| 4832 |
+
const rx = margin, ry = margin, rr = 4;
|
| 4833 |
+
ctx.beginPath();
|
| 4834 |
+
ctx.moveTo(rx + rr, ry);
|
| 4835 |
+
ctx.arcTo(rx + w, ry, rx + w, ry + h, rr);
|
| 4836 |
+
ctx.arcTo(rx + w, ry + h, rx, ry + h, rr);
|
| 4837 |
+
ctx.arcTo(rx, ry + h, rx, ry, rr);
|
| 4838 |
+
ctx.arcTo(rx, ry, rx + w, ry, rr);
|
| 4839 |
+
ctx.closePath(); ctx.fill();
|
| 4840 |
+
ctx.restore();
|
| 4841 |
+
// photo, object-fit: cover into the photo square
|
| 4842 |
+
const dx = margin + pad, dy = margin + pad;
|
| 4843 |
+
const ir = img.width / img.height, dr = 1; // square target
|
| 4844 |
+
let sw, sh, sx, sy;
|
| 4845 |
+
if (ir > dr) { sh = img.height; sw = sh * dr; sx = (img.width - sw) / 2; sy = 0; }
|
| 4846 |
+
else { sw = img.width; sh = sw / dr; sx = 0; sy = (img.height - sh) / 2; }
|
| 4847 |
+
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, photo, photo);
|
| 4848 |
+
// thin inner edge around the photo
|
| 4849 |
+
ctx.strokeStyle = "rgba(0,0,0,0.10)"; ctx.lineWidth = 1;
|
| 4850 |
+
ctx.strokeRect(dx + 0.5, dy + 0.5, photo - 1, photo - 1);
|
| 4851 |
+
resolve(cv);
|
| 4852 |
+
} catch (e) { reject(e); }
|
| 4853 |
+
};
|
| 4854 |
+
img.onerror = reject;
|
| 4855 |
+
img.src = src;
|
| 4856 |
+
});
|
| 4857 |
+
}
|
| 4858 |
+
|
| 4859 |
function buildCesiumStyle(Cesium, kind, colorway) {
|
| 4860 |
if (kind === "realistic") {
|
| 4861 |
// Solid, light buildings (no transparency) so sun lighting can model
|
|
|
|
| 5015 |
baseLayerPicker: false, geocoder: false, homeButton: false,
|
| 5016 |
sceneModePicker: false, navigationHelpButton: false, animation: false,
|
| 5017 |
timeline: false, fullscreenButton: false, infoBox: false, selectionIndicator: false,
|
| 5018 |
+
// Render on demand instead of every single frame: the globe only
|
| 5019 |
+
// redraws when something actually changes (camera move, tile load,
|
| 5020 |
+
// a pin edit). This removes the constant-repaint jank when the view
|
| 5021 |
+
// is idle. The journey flythrough flips this off while it runs (see
|
| 5022 |
+
// playFly), since it animates the clock every frame.
|
| 5023 |
+
requestRenderMode: true, maximumRenderTimeChange: Infinity,
|
| 5024 |
});
|
| 5025 |
viewerRef.current = viewer;
|
| 5026 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
| 5027 |
+
// Smoother edges (anti-aliasing) on buildings + polaroids, and gentle
|
| 5028 |
+
// camera inertia so panning/zooming glides to a stop instead of
|
| 5029 |
+
// snapping. Wrapped in try/catch as these vary across Cesium builds.
|
| 5030 |
+
try { viewer.scene.msaaSamples = 4; } catch (e) {}
|
| 5031 |
+
try {
|
| 5032 |
+
const ctl = viewer.scene.screenSpaceCameraController;
|
| 5033 |
+
ctl.inertiaSpin = 0.85; ctl.inertiaTranslate = 0.85; ctl.inertiaZoom = 0.85;
|
| 5034 |
+
} catch (e) {}
|
| 5035 |
viewer.scene.camera.setView({
|
| 5036 |
destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
|
| 5037 |
orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
|
|
|
|
| 5076 |
tilesetRef.current = tileset;
|
| 5077 |
tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
|
| 5078 |
} catch (e) { console.warn("[cesium] OSM buildings failed", e); }
|
| 5079 |
+
// In requestRenderMode the scene paints on demand, so force one
|
| 5080 |
+
// render now that imagery + buildings + post-process are all set up.
|
| 5081 |
+
try { viewer.scene.requestRender(); } catch (e) {}
|
| 5082 |
if (!cancelled) setStatus("ready");
|
| 5083 |
} catch (e) {
|
| 5084 |
if (!cancelled) { setStatus("error"); setErrMsg(e.message || "Cesium failed to load."); }
|
|
|
|
| 5131 |
})();
|
| 5132 |
}
|
| 5133 |
prevStyleRef.current = style;
|
| 5134 |
+
// on-demand render so style/colour/imagery swaps show without a nudge
|
| 5135 |
+
try { viewer.scene.requestRender(); } catch (e) {}
|
| 5136 |
}, [style, colorway, status]);
|
| 5137 |
|
| 5138 |
+
// render pinned photos as larger Polaroid-framed billboards (no caption
|
| 5139 |
+
// text). Each photo is composed onto a white Polaroid card on a canvas
|
| 5140 |
+
// first, so the globe shows framed prints instead of bare squares.
|
| 5141 |
useEffect(() => {
|
| 5142 |
const viewer = viewerRef.current;
|
| 5143 |
if (!viewer || !window.Cesium || status !== "ready") return;
|
| 5144 |
const Cesium = window.Cesium;
|
| 5145 |
+
let cancelled = false;
|
| 5146 |
entitiesRef.current.forEach((e) => { try { viewer.entities.remove(e); } catch (_) {} });
|
| 5147 |
entitiesRef.current = [];
|
| 5148 |
+
(async () => {
|
| 5149 |
+
for (const p of pinnedStore) {
|
| 5150 |
+
let image = p.src, aspect = 0.86;
|
| 5151 |
+
try {
|
| 5152 |
+
const cv = await makePolaroidCanvas(p.src);
|
| 5153 |
+
image = cv; aspect = cv.width / cv.height;
|
| 5154 |
+
} catch (_) { /* fall back to the raw photo */ }
|
| 5155 |
+
if (cancelled || !viewerRef.current) return;
|
| 5156 |
+
const W = 132; // on-screen width (was 86, square)
|
| 5157 |
+
const ent = viewer.entities.add({
|
| 5158 |
+
id: "sozai-" + p.id,
|
| 5159 |
+
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 40),
|
| 5160 |
+
billboard: {
|
| 5161 |
+
image, width: W, height: Math.round(W / aspect),
|
| 5162 |
+
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
| 5163 |
+
heightReference: Cesium.HeightReference.NONE,
|
| 5164 |
+
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
| 5165 |
+
},
|
| 5166 |
+
});
|
| 5167 |
+
entitiesRef.current.push(ent);
|
| 5168 |
+
}
|
| 5169 |
+
try { viewer.scene.requestRender(); } catch (_) {}
|
| 5170 |
+
})();
|
| 5171 |
+
return () => { cancelled = true; };
|
| 5172 |
+
}, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat).join("|"), status]);
|
| 5173 |
|
| 5174 |
// tap-to-pin + drag-to-move on the globe.
|
| 5175 |
const armedRef = useRef(null);
|
|
|
|
| 5196 |
handler.setInputAction((movement) => {
|
| 5197 |
if (!dragId) return;
|
| 5198 |
const cart = viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid);
|
| 5199 |
+
if (cart) { const { lon, lat } = toLngLat(cart); const ent = viewer.entities.getById("sozai-" + dragId); if (ent) ent.position = Cesium.Cartesian3.fromDegrees(lon, lat, 40); try { viewer.scene.requestRender(); } catch (_) {} }
|
| 5200 |
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
| 5201 |
// LEFT_UP: commit a drag, OR pin an armed photo at the click point
|
| 5202 |
handler.setInputAction((click) => {
|
|
|
|
| 5247 |
try { viewer.clock.shouldAnimate = false; } catch (e) {}
|
| 5248 |
if (clockTickRef.current) { try { clockTickRef.current(); } catch (e) {} clockTickRef.current = null; }
|
| 5249 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5250 |
+
// restore on-demand rendering after an interrupted journey
|
| 5251 |
+
try { viewer.scene.requestRenderMode = true; viewer.scene.requestRender(); } catch (e) {}
|
| 5252 |
}
|
| 5253 |
removeVehicle();
|
| 5254 |
}
|
|
|
|
| 5259 |
flyAbortRef.current = { stop: false };
|
| 5260 |
const token = flyAbortRef.current;
|
| 5261 |
setFlying(true);
|
| 5262 |
+
// the journey animates the clock every frame, so render continuously
|
| 5263 |
+
// while it plays (restored to on-demand in stopFly / at the end).
|
| 5264 |
+
try { viewer.scene.requestRenderMode = false; } catch (e) {}
|
| 5265 |
|
| 5266 |
const tdef = CS_TRANSPORTS.find((t) => t.id === transport) || CS_TRANSPORTS[0];
|
| 5267 |
const isPlane = tdef.id === "plane";
|
|
|
|
| 5386 |
}
|
| 5387 |
removeVehicle();
|
| 5388 |
try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
|
| 5389 |
+
// back to on-demand rendering now that the journey is over
|
| 5390 |
+
try { viewer.scene.requestRenderMode = true; viewer.scene.requestRender(); } catch (e) {}
|
| 5391 |
setFlying(false); setFlyIdx(-1);
|
| 5392 |
}
|
| 5393 |
|