handwritten-letter

在路上

在路上是直式手機網頁原型。信依真實地理距離計算郵寄時間,預設像真正的國際信件,要等好幾天。地圖上看得到目前位置、剩餘距離與預計抵達。還沒有完整帳號,也不用電子郵件或密碼:第一次來先留暱稱、確認已滿 16 歲,再設定「我的地區」。編號、暱稱和地區存在這台瀏覽器,之後每封信都從那個地區寄出。伺服器另外記住暱稱、城市和建立時間。

畫面的顏色、字體、紙張、印刷框、郵戳、陰影和動畫節奏寫在 紙本郵局視覺規範。

本機執行

需要 Node.js 18 以上。

npm install
npm test
npm start

瀏覽器打開 http://localhost:3000。畫面是直式欄位,手機寬度會滿版。

主路徑:第一次來先留暱稱並確認年齡,再設定我的地區 → 抽一位收件人 → 地圖上先看到對方的暱稱和城市 → 寫信或上傳照片 → 若附言裡有電話、電子郵件、網址或社群帳號,會在寄件回執之前被留下 → 確認寄出,這封信不能收回 → 蓋戳、投入郵筒 → 看信鴿沿著一條線飛。寄給真人的信,對方在「我的信箱」等到信到了才拆,按「已收到」之後才算送達。寄給示範收件人的信,時間到了就在這台裝置上拆。重新整理後,在路上的信仍停在當時的軌跡位置。伺服器重新啟動也一樣,信記在 data/letters.json,寄件人記在 data/senders.json。地區和暱稱也記在這台瀏覽器。

換成可玩快

預設是浪漫慢。要在一分鐘內看完寄出、在路上、拆信,用可玩快:

  1. 寫信時按「可玩快」。
  2. 打開網址時加上 ?pace=playable-fast,例如 http://localhost:3000/?pace=playable-fast。這個選擇會留在這次瀏覽。
  3. 啟動時設定環境變數 PACE=playable-fast。請求沒有自訂節奏時,伺服器與畫面都用可玩快,首頁那些一直在跑的示範旅途也一起變快。
PACE=playable-fast npm start

改回浪漫慢:拿掉環境變數,或設 PACE=romantic-slow,或網址用 ?pace=romantic-slow。兩個名字只接受 romantic-slow 和 playable-fast。

部署

這是 Node 伺服器,不是純靜態網站,不能只丟上 GitHub Pages。程序讀 PORT,預設聽 0.0.0.0。健康檢查是 GET /api/health。

公開網址用 Render 的免費 Web Service。倉庫根目錄的 render.yaml 就是 Blueprint:

  1. 把這個倉庫推上 GitHub。
  2. 打開 Render → New → Blueprint,選這個倉庫。
  3. 確認服務名稱是 handwritten-letter、方案是 Free,套用。
  4. 部署完成後的網址是 https://handwritten-letter.onrender.com。若名稱已被占用,Render 會請你改名,網址變成 https://<服務名稱>.onrender.com。

本機用 Docker 也可以:

docker build -t handwritten-letter .
docker run --rm -p 3000:3000 handwritten-letter

要把信留在容器裡,加上 -v "$(pwd)/data:/app/data"。

磁碟: 信件和信面寫在 data/。Render 免費方案的磁碟是暫時的,重新部署會清空;閒置約 15 分鐘服務會休眠,喚醒後本機檔案也不保證還在。這份原型可以接受。若要寄出的信在重新部署後還在,改用付費方案並把 persistent disk 掛到 data/。不查航班、也不需要帳號,所以沒有另外接資料庫。

地圖圖磚向 OpenStreetMap 要,部署環境要能連外網。

寄信有兩條路。一條是抽一位隨機收件人,路線和郵寄時間跟著對方住的城市,從你的地區算起。另一條是寄給一位朋友:寫下稱呼、從同一份城市名單或地圖上選對方的城市,飛行時間仍照真實距離算。預設是浪漫慢。想很快看完降落,用上面的可玩快。兩條路都要先看寄件回執,投入郵筒之後就不能收回,也都占用同時寄出的名額。

想清空資料,停掉伺服器後刪除 data/letters.json 和 data/images/。

我的地區

寄出地不在每一封信上選。第一次打開會先問暱稱,並要勾選「我已滿 16 歲」。未滿 16 歲不能使用。接著問「我的地區」:從名單點一個城市,或在地圖上點,會對到最近的城市。確定之後寫進這台瀏覽器(localStorage,鍵名 on-the-way-sender)。格式是 { version: 1, originId, senderId, nickname, ofAge }。senderId 是這台瀏覽器的寄件人編號。還沒有帳號,也沒有密碼;以後若有帳號,同一份地區、暱稱和編號可以改存到帳號上,不再讀這個鍵。較早只存了地區的瀏覽器,下次會再問一次暱稱和年齡,編號和地區維持不變。

寫信、抽收件人時只把地區當成回郵地址顯示,不能在那裡改。首頁標題旁有一行很小的「地區 · 城市」,那才是設定,跟寄出分開。改地區只影響之後的信,寄件人編號不變。已經寄出的信,寄出地記在那封信上,不會跟著改。

同時在路上

同一寄件人編號能同時寄出幾封,看這位已經送達幾封。參考 Postcrossing,上限 10 封。階梯的形狀參考 Postcrossing 公開的寄送數量說明。Postcrossing 的真實上限最高是 100 封。10 封是我們自己訂的上限。他們六十天沒被登記就過期;我們的信一定會到,所以沒有過期。

已送達 可同時在路上
0 到 4 封 5 封
5 到 14 封 6 封
15 到 24 封 7 封
25 到 34 封 8 封
35 到 49 封 9 封
50 封或更多 10 封

草稿不算。沒有寄件人編號的舊信,不論在路上或已送達,都不算。畫面上的郵票格跟目前的上限一樣多:寄出的那幾格蓋了郵戳,旁邊寫「還可寄 2 封」這類字。還沒到十格時,會寫「再送達 3 封就多一格」這類字。格數滿了就不能再寄,會寫最早的那封何時送到。可玩快用同一張表。

寄給真人的信,信鴿落地之後先停在「已到信箱,等待對方拆收」,這一格還佔著,也不算已送達。對方拆開並按「已收到」,才算已送達,格子空出來,也才計入上面的階梯。示範收件人沒有人可以按,時間到了就直接算已送達。已經寄給示範收件人的舊信也一樣。寄給朋友的信不進信箱,也沒有人可以按已收到,信鴿落地就算已送達,那一格就空出來。朋友改用專屬連結,在送到之後十四天內拆信。

寄出時把寄件人編號交給伺服器,寫在那封信上。超過上限時伺服器拒絕,例如「路上已經有 5 封信。等最早的那封送到,才能再寄。」

這是瀏覽器裡的編號,還沒有帳號。換一台瀏覽器、清掉這個網站的資料,或自己換一個編號,這個上限就重新算。真正的帳號做出來之前,這個上限擋得住一般使用,擋不住有意避開。

清掉瀏覽器裡這個網站的資料,下次會再問一次地區。只刪 data/letters.json 不會忘掉地區。

隨機收件人

已滿 16 歲、留過暱稱的人會記在伺服器上。抽籤時若這樣的人至少有兩位,就從其他人裡隨機抽一位:不會抽到自己,也盡量不連續抽到剛剛那位。對方若封鎖了你,就不會再抽到對方,對方也不會收到通知。真人不到兩位,或剩下的人都不能抽,就退回 20 位示範收件人,畫面上會標明「這是示範收件人」。示範名單分在亞洲、歐洲、非洲、美洲、大洋洲的真實城市,也是可以選的地區。抽籤在伺服器:示範收件人會避開你的地區所在城市,也避開剛剛抽到、或這台裝置剛寄出的那位。飛行時間用下面的距離公式,從你的地區算到對方住的城市,不另外加速。畫面上只寫城市,不寫精確位置;地圖上的點是該城市,不是某一條街。

抽出之後先停在地圖上,確認名字、城市、那一條線和郵寄時間,再進入寫信。

以前從首頁固定路線寄出、已經寫進 data/letters.json 的信,座標和時間都記在那封信上,仍會照原路顯示、照原定時間抵達。那些地標(台北、台北101、台中、高雄、東京)不再出現在畫面上,但伺服器仍認得這些編號,舊草稿也能繼續寄出。直接打開 #/compose 或舊的固定路線網址,會回到抽收件人。

寄給一位朋友

寫信時除了抽一位隨機收件人,也可以寄給一位朋友。寄件人寫下朋友的稱呼,再從同一份城市名單選城市,或在地圖上點,會對到最近的城市。不能選自己的地區。飛行時間用下面的距離公式,從你的地區算到那個城市,不另外加速。

寄出前仍是郵局櫃檯的寄件回執。確認之後蓋戳、投入郵筒,寄出後就不能收回,也算進同時寄出的名額,上限一樣是 10 封。這封信不進「我的信箱」,也不等對方按已收到。朋友的稱呼和附言若含電話、電子郵件、網址或社群帳號,會在投入郵筒之前被留下。信面圖片在存檔前會拿掉位置與拍攝資訊。

我們不寄電子郵件,也不傳簡訊。寄出後畫面上有一條專屬連結,請寄件人自己轉給朋友。連結的代碼是 32 個隨機位元組,不是流水號,不能靠著往下數猜到下一封。

朋友打開連結不用註冊。信還在路上時,只看得到地圖上的信鴿、沿途已經蓋上的郵戳,和預計抵達的日子,看不到信的內容。送到之後才能拆信,拆信的步驟和寄件人自己拆信相同。連結在送到之後十四天內有效。過了就只剩一張「這封信已經退回郵局」的紙條。拆完信之後,頁面下方有一行「也寄一封給遠方的陌生人」,連回首頁。這台瀏覽器若還沒有設定地區,就會走進第一次進站的流程。

分享頁

每一封已經寄出的信還有一個公開的分享頁,網址同樣是一長串隨機代碼。頁面上只有信封正面、郵票、沿途郵戳、起訖城市和路上的天數,沒有信的內容,也沒有朋友的稱呼。

貼到 Threads 或 Instagram 時,開放圖譜會帶一張預覽圖。那張圖由伺服器畫成復古信封,用程式裡的字型把城市和天數寫上去,不是人工智慧生成的圖片。朋友的專屬連結過期之後,分享頁仍只顯示這張信封,不顯示信面。

信鴿

每一封信由一隻信鴿送。地圖上是一條墨線,從你的地區畫到收件人的城市,沿大圓過去。信鴿是一枚洋紅墨的戳,套印略微不準,沿著這條線往前。沒有公路、火車、飛機,也沒有中途轉運。

總時間仍用下面的公式,只看寄出地到收件地的大圓距離。換畫法不改天數。例如台北到紐約仍是大約十天。可玩快也還在,只是同一隻信鴿飛得比較快,方便看完寄出和抵達。

已經寫進信件檔、而且存著好幾段路程的舊信,打開時也畫成這一條線。信鴿的位置是寄出時間到送達時間的比例,不跟著舊的路段走。時間到了仍會抵達。

沿途郵戳

信封背面蓋這趟飛行經過的國家或主要城市。寄出地和收件地一定有戳。中途的戳照飛行進度出現:信鴿飛到那裡才蓋,還沒飛到的不蓋。送到時再蓋一枚「已送達」。

站點來自一份放在程式裡的城市名單。沿著大圓每隔一段取一個點,對到最近的城市。那個城市仍然太遠,就當成海上,不蓋。沒有接外面的付費服務。

郵戳是程式畫的向量圖:雙圈、城市、國家和日期,墨色有深淺,角度略歪。不是另外做的圖片。飛行畫面和拆信之後都可以打開「信封背面」。

郵戳護照

首頁可以打開郵戳護照。這位寄件人的信飛過、或已經送到的國家,各留一枚戳,以第一次經過那個國家的時間為準。這是免費的。沒有付費,沒有稀有,也沒有抽取。

還沒有帳號,用瀏覽器裡的寄件人編號來算。沒有寄件人編號的舊信不會進這本護照。那些信本身仍照常顯示,時間到了仍會送到。

沒寄出的信

寫到一半會自動存在這台瀏覽器:畫布上的筆跡、上傳的信面、抽到的收件人。圖片可能很大,所以放在瀏覽器自己的資料庫裡,不單靠較小的本機儲存。重新整理或關掉分頁再打開,首頁會問「還有一封沒寄出的信,要接著寫嗎?」。可以接著寫,也可以丟掉草稿。丟掉之前會再問一次,確認筆跡和照片都會消失。

圖片大到瀏覽器存不下時,會先改存比較小的版本。還是放不下,就留下收件人,並告訴你筆跡這次沒存成。寄出成功之後,這份瀏覽器裡的草稿會清掉。

我的信箱

寄給你、而且收件人是你的編號的信,列在「我的信箱」。還在路上的不能拆,只看得到「有一封信正在飛來,預計某日抵達」。時間到了才能沿用原來的拆信儀式。拆開之後按「已收到」,這封信才算已送達。

寄件人那一邊,信已經落地但對方還沒按的時候,寫著「已到信箱,等待對方拆收」。

安全底線

附言和朋友的稱呼若含電話、電子郵件、網址或社群帳號(例如 @帳號、LINE 帳號),伺服器拒絕投郵,並在寄件回執出現之前告訴寄件人原因。信面圖片先不做自動辨識。上傳或貼上的圖片,不論寄給真人或寄給朋友,在存檔前都會拿掉位置與拍攝資訊。

收件人可以檢舉或封鎖寄件人。紀錄留在伺服器(data/reports.json、data/blocks.json),沒有後台。對方不會收到通知。被封鎖的人以後抽不到這位收件人。

寄出與拆信

按「寄出」不會立刻進郵筒。先出現一張郵局櫃檯的寄件回執:寫明一旦投入郵筒就不能收回、也不能再改,並列出收件人、收件城市、寄自哪裡。預計抵達寫成「預計 10 月 14 日抵達紐約」這種句子,並寫上收件地的當地時間。日期和鐘點用收件城市的時區。要再按「確認寄出」才算寄出;「先不寄」會回到信上。這一步不是再點一次同一個按鈕。

確認之後,才在信面蓋上「已寄出」的戳:墨會暈開,並且略微套印不準。接著信滑進郵筒的投口,筒蓋掀一下又蓋上。畫面再慢慢交到地圖,從寄出地跟著拉遠,不是直接切過去。

抵達後按「拆信」:先看到信封、郵票貼紙和郵戳。撕條拉掉、封口掀開,信才從袋口滑出來。拆開後補一小行:在路上多久、信鴿飛了多遠。飛行中讀信面仍然是 403。

正在路上

首頁和飛行畫面會顯示「N 封信正在路上」。數字包含這台伺服器上真實飛行中的信,加上 8 條一直在跑的示範旅途。那些旅途在地圖上是淡點,沒有信面,也沒有內文。

飛行時間怎麼算

距離是兩點的大圓距離(haversine,地球半徑 6371.0088 公里)。

浪漫慢(預設 romantic-slow)先看這封信屬於哪一種路程,再用距離在那個範圍裡取天數,最後加上一個由起迄座標決定的固定微調。同城大約正負兩小時,其餘大約正負四小時。同一條路線每次一樣,微調也不會把天數推出該種路程的範圍。

路程 怎麼認定 天數
同城 少於 80 公里 約 1 天。先取 26 小時,距離再往上加,最多再加 2 小時
國內 同一個國家,而且不是同城 2 到 3 天。80 公里靠近 2 天多,5000 公里靠近 3 天,中間用距離的對數
鄰近區域 同一個洲、不同國家,而且少於 6000 公里 4 到 6 天。例如東亞。300 公里靠近 4 天多,6000 公里靠近 6 天,中間用距離的對數
跨洲或很遠 不同的洲,或同一個洲但達到 6000 公里 7 到 14 天

跨洲或很遠:

天數 = 夾在 7 與 14 之間(7 + 7 × (距離公里 − 6500) / 12500)

6500 公里是 7 天,19000 公里是 14 天,中間是直線。台北到倫敦約 9781 公里,落在 8 到 10 天。

下面是算過微調之後的實際時間。

路線 距離 路程 浪漫慢 可玩快
台北 → 台北101 5.0 公里 同城 1 天 3 小時 27 秒
台北 → 台中 132 公里 國內 2 天 3 小時 2 分 18 秒
台北 → 高雄 296 公里 國內 2 天 11 小時 3 分 27 秒
紐約 → 洛杉磯 3937 公里 國內 2 天 19 小時 12 分 33 秒
台北 → 香港 810 公里 鄰近區域 4 天 15 小時 5 分 42 秒
台北 → 東京 2107 公里 鄰近區域 5 天 9 分 11 秒
台北 → 新加坡 3252 公里 鄰近區域 5 天 5 小時 11 分 24 秒
台北 → 雪梨 7264 公里 跨洲 7 天 9 小時 17 分 3 秒
台北 → 倫敦 9781 公里 跨洲 8 天 19 小時 18 分鐘
台北 → 紐約 12525 公里 跨洲 10 天 5 小時 18 分鐘
台北 → 里約熱內盧 18442 公里 跨洲 13 天 14 小時 18 分鐘

台北到倫敦是 8 天 19 小時,整段都由同一隻信鴿飛完。台北到紐約是 10 天 5 小時。時間不因畫法而拆開。

可玩快(playable-fast,測試用):

秒數 = 夾在 25 與 1080 之間(12 × √距離公里)

寄出時後端寫入 departedAt、arrivesAt 和狀態(draft / in_flight / awaiting_receipt / delivered)。進度只看時間:

進度 = (現在 − 寄出時間) / (送達時間 − 寄出時間)

位置是這條大圓上的同一個比例。剩餘距離是整段距離乘上還沒走完的比例。舊信即使存了好幾段路程,也用同一種算法。時間到了,寄給真人的信改成等待對方按已收到;寄給示範收件人或朋友的信改成已送達。在路上不能讀信面,抵達後才打得開。

畫面上的倒數在一天以上會寫成「還有 8 天 5 小時」,不再堆成一大串分鐘。預計抵達用這台瀏覽器自己的時區,並且帶日期,例如「10月7日 下午3:20」。

API

方法 路徑 說明
POST /api/senders 登記暱稱、城市與年齡確認。未滿 16 歲會被拒絕
GET /api/mailbox?senderId= 寄給這位、已經寄出的信。飛行中不附信面與附言
GET /api/recipients 示範收件人名單
POST /api/recipients/draw 抽一位。可帶 fromId、excludeId、senderId。真人不足時退回示範名單
POST /api/letters/:id/receive 收件人按已收到
POST /api/letters/:id/report 收件人檢舉,不通知寄件人
POST /api/letters/:id/block 收件人封鎖寄件人,不通知對方
GET /api/route?fromId&toId 某組起迄的距離、飛行時間與分段
GET /api/activity 正在路上的信數量,與淡點旅途(不含信面)
GET /api/letters 信件與目前進度
POST /api/letters 建立。launch: true 會直接寄出
PUT /api/letters/:id 更新草稿
POST /api/letters/:id/throw 草稿寄出(舊路徑,仍可用)
POST /api/letters/:id/send 草稿寄出,與 /throw 同一件事
GET /api/letters/:id 單封信,含位置與剩餘時間
GET /api/letters/:id/image 信面。飛行中回 403
GET /api/open/:code 朋友的專屬連結。飛行中不含信面;送到十四天後只有退回紙條
GET /api/open/:code/image 朋友拆信用的信面。飛行中 403,過期 410
GET /s/:code 分享頁。只有信封、郵戳、城市和天數
GET /s/:code/card.png 分享頁的開放圖譜預覽圖,伺服器繪製
GET /f/:code 朋友打開專屬連結的頁面

建立信件的 JSON:

{
  "fromId": "taipei",
  "toId": "ellen",
  "pace": "romantic-slow",
  "imageDataUrl": "data:image/png;base64,...",
  "launch": true
}

fromId 是這台瀏覽器設定的地區,每一封信帶上同一個寄出地,不是在信上另選的。toId 用收件人編號(上面的例子是住在倫敦的 Ellen)。寄給朋友時,toId 改為城市編號,並加上 friendName(朋友的稱呼)。較早的請求若仍帶地標編號,例如 kaohsiung 或 taipei101,伺服器仍會算出座標,所以已經寫進 data/letters.json 的信不受影響。

pace 只接受 romantic-slow 或 playable-fast。沒寫的時候用伺服器預設,也就是浪漫慢;若啟動時設了環境變數 PACE=playable-fast,沒寫的請求就改用可玩快。

地圖圖磚是 OpenStreetMap。