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.

Files changed (2) hide show
  1. docs/video_script.md +225 -0
  2. 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: 1400, essential: true });
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: 1100, essential: true });
4518
- await new Promise((r) => setTimeout(r, 1300)); // dwell to show the photo
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: 2000 }); } catch (e) {}
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 + labels
 
 
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
- pinnedStore.forEach((p) => {
5071
- const ent = viewer.entities.add({
5072
- id: "sozai-" + p.id,
5073
- position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, 40),
5074
- billboard: {
5075
- image: p.src, width: 86, height: 86,
5076
- verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
5077
- heightReference: Cesium.HeightReference.NONE,
5078
- disableDepthTestDistance: Number.POSITIVE_INFINITY,
5079
- },
5080
- label: {
5081
- text: (p.caption || p.title || ""),
5082
- font: "13px sans-serif", fillColor: Cesium.Color.WHITE,
5083
- showBackground: true, backgroundColor: new Cesium.Color(0.15, 0.15, 0.13, 0.85),
5084
- pixelOffset: new Cesium.Cartesian2(0, 14),
5085
- verticalOrigin: Cesium.VerticalOrigin.TOP,
5086
- disableDepthTestDistance: Number.POSITIVE_INFINITY,
5087
- scale: 0.85,
5088
- },
5089
- });
5090
- entitiesRef.current.push(ent);
5091
- });
5092
- }, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption).join("|"), status]);
 
 
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