@ -0,0 +1,24 @@ |
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
lerna-debug.log* |
||||
|
||||
node_modules |
||||
dist |
||||
dist-ssr |
||||
*.local |
||||
|
||||
# Editor directories and files |
||||
.vscode/* |
||||
!.vscode/extensions.json |
||||
.idea |
||||
.DS_Store |
||||
*.suo |
||||
*.ntvs* |
||||
*.njsproj |
||||
*.sln |
||||
*.sw? |
||||
@ -0,0 +1,16 @@ |
||||
# React + Vite |
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. |
||||
|
||||
Currently, two official plugins are available: |
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh |
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh |
||||
|
||||
## React Compiler |
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). |
||||
|
||||
## Expanding the ESLint configuration |
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. |
||||
@ -0,0 +1,29 @@ |
||||
import js from '@eslint/js' |
||||
import globals from 'globals' |
||||
import reactHooks from 'eslint-plugin-react-hooks' |
||||
import reactRefresh from 'eslint-plugin-react-refresh' |
||||
import { defineConfig, globalIgnores } from 'eslint/config' |
||||
|
||||
export default defineConfig([ |
||||
globalIgnores(['dist']), |
||||
{ |
||||
files: ['**/*.{js,jsx}'], |
||||
extends: [ |
||||
js.configs.recommended, |
||||
reactHooks.configs.flat.recommended, |
||||
reactRefresh.configs.vite, |
||||
], |
||||
languageOptions: { |
||||
ecmaVersion: 2020, |
||||
globals: globals.browser, |
||||
parserOptions: { |
||||
ecmaVersion: 'latest', |
||||
ecmaFeatures: { jsx: true }, |
||||
sourceType: 'module', |
||||
}, |
||||
}, |
||||
rules: { |
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], |
||||
}, |
||||
}, |
||||
]) |
||||
@ -0,0 +1,13 @@ |
||||
{ |
||||
"hosting": { |
||||
"source": ".", |
||||
"ignore": [ |
||||
"firebase.json", |
||||
"**/.*", |
||||
"**/node_modules/**" |
||||
], |
||||
"frameworksBackend": { |
||||
"region": "asia-east1" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
<!doctype html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>GRAYSCALE</title> |
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"> |
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@100..900&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet"> |
||||
|
||||
</head> |
||||
<body> |
||||
<div id="root"></div> |
||||
<script type="module" src="/src/main.jsx"></script> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,31 @@ |
||||
{ |
||||
"name": "source-web-23070-grayscale", |
||||
"private": true, |
||||
"version": "0.0.0", |
||||
"type": "module", |
||||
"scripts": { |
||||
"dev": "vite", |
||||
"build": "vite build", |
||||
"lint": "eslint .", |
||||
"preview": "vite preview" |
||||
}, |
||||
"dependencies": { |
||||
"@tailwindcss/vite": "^4.1.18", |
||||
"react": "^19.2.0", |
||||
"react-dom": "^19.2.0", |
||||
"react-router-dom": "^7.13.0", |
||||
"swr": "^2.4.0", |
||||
"tailwindcss": "^4.1.18" |
||||
}, |
||||
"devDependencies": { |
||||
"@eslint/js": "^9.39.1", |
||||
"@types/react": "^19.2.5", |
||||
"@types/react-dom": "^19.2.3", |
||||
"@vitejs/plugin-react": "^5.1.1", |
||||
"eslint": "^9.39.1", |
||||
"eslint-plugin-react-hooks": "^7.0.1", |
||||
"eslint-plugin-react-refresh": "^0.4.24", |
||||
"globals": "^16.5.0", |
||||
"vite": "^7.2.4" |
||||
} |
||||
} |
||||
|
After Width: | Height: | Size: 181 B |
@ -0,0 +1,320 @@ |
||||
{ |
||||
"title": { |
||||
"en": "GRAYSCALE", |
||||
"zh": "灰度" |
||||
}, |
||||
"button_enter": { |
||||
"en": "enter", |
||||
"zh": "進入" |
||||
}, |
||||
"button_explore": { |
||||
"en": "explore", |
||||
"zh": "開始探索" |
||||
}, |
||||
"button_reselect": { |
||||
"en": "reselect", |
||||
"zh": "重新選擇" |
||||
}, |
||||
"title_keywords": { |
||||
"en": "Select 4 signals to observe.", |
||||
"zh": "選擇四個想觀測的訊號" |
||||
}, |
||||
"title_explore": { |
||||
"en": "Observed the dialogues in the installation.", |
||||
"zh": "觀測儀器裡的對話" |
||||
}, |
||||
"title_observe": { |
||||
"en": "Observe the installation...", |
||||
"zh": "觀測儀器..." |
||||
}, |
||||
"title_about": { |
||||
"en": "About", |
||||
"zh": "關於作品" |
||||
}, |
||||
"content_about": { |
||||
"en": "What defines an \"adult\"? In reality, the standards are never consistent. We are expected to be mature and responsible, yet we are often treated as individuals who are not quite \"ready\". This unclassifiable state is our shared experience.\nThe visuals here are culled from fragmented social media discussions on career, age, and social norms. Though scattered, they all point to the same \"gray dilemma\" occurring everywhere.\nThrough AI, these isolated voices are aggregated into this single device. It offers no answers, but forms a new shape from these dialogues—inviting you to step closer and observe.", |
||||
"zh": "作品源於 2025 年陽明交大應藝所、建築所與害喜影音綜藝共同發起的一場實踐。相較於校園中常見的傳統公共藝術,我們試圖從學習者的視角出發,將那些發生在校園裡的迷惘與不確定,變成一場關於公共性的思辨。\n我們在思考:究竟什麼是「長大成人」?在生活中,標準從未真正一致。隨著年紀增長,人被期待表現成熟、負責,但又常常在社會中,被視為還沒準備好的個體。這種難以歸類的狀態,構成共同經驗。\n你所看到的影像,來自許多人在社群平台上,對於年紀、職場、生涯規劃與社會規範留下的討論。這些發言短暫、零散,卻指向同一種灰色的困境——不論幾歲、不論在哪,都可能正發生。\n透過 AI 與數位,這些原本彼此獨立的討論被放在同一個裝置裡。作品並非試圖給出答案,而是將這些對話聚集在一起,形成一個新的形狀,邀請你靠近觀察。" |
||||
}, |
||||
"title_credits": { |
||||
"en": "Credit", |
||||
"zh": "製作團隊" |
||||
}, |
||||
"content_credits": { |
||||
"en": "Artwork by ULTRACOMBOS", |
||||
"zh": "Artwork by ULTRACOMBOS" |
||||
}, |
||||
"title_share": { |
||||
"en": "SHARE YOUR THOUGHTS", |
||||
"zh": "分享你的想法" |
||||
}, |
||||
"keywords": { |
||||
"en": [ |
||||
{ |
||||
"title": "Overworked", |
||||
"content": "How much of your schedule is actually for yourself?" |
||||
}, |
||||
{ |
||||
"title": "Internal Burnout", |
||||
"content": "Do you spend a lot of energy just managing your own emotions?" |
||||
}, |
||||
{ |
||||
"title": "Student Loans", |
||||
"content": "How many years will it take to pay off that diploma?" |
||||
}, |
||||
{ |
||||
"title": "Renting", |
||||
"content": "What slice of your paycheck goes straight to the landlord?" |
||||
}, |
||||
{ |
||||
"title": "Debt", |
||||
"content": "Do you feel like you owe anyone \"time\" or \"favors\"?" |
||||
}, |
||||
{ |
||||
"title": "Adulthood", |
||||
"content": "What’s the gap between your legal age and how old you actually feel?" |
||||
}, |
||||
{ |
||||
"title": "Survival", |
||||
"content": "Are you living right now, or just surviving?" |
||||
}, |
||||
{ |
||||
"title": "Discipline", |
||||
"content": "Which social habits were you born with, and which did you learn to fit in?" |
||||
}, |
||||
{ |
||||
"title": "PUA", |
||||
"content": "Is the self-doubt coming from the task, or someone else’s judgment?" |
||||
}, |
||||
{ |
||||
"title": "Consumable", |
||||
"content": "If you took tomorrow off, would the system really stop?" |
||||
}, |
||||
{ |
||||
"title": "Servility", |
||||
"content": "What’s your gut reaction to an unreasonable order?" |
||||
}, |
||||
{ |
||||
"title": "Unwritten Rules", |
||||
"content": "Which unspoken rules are you quietly following?" |
||||
}, |
||||
{ |
||||
"title": "Corporate Slave", |
||||
"content": "How do you tell the \"work you\" apart from the \"real you\"?" |
||||
}, |
||||
{ |
||||
"title": "Curfew", |
||||
"content": "When is your most comfortable time to be home?" |
||||
}, |
||||
{ |
||||
"title": "Boundaries", |
||||
"content": "When do you say \"no\" just to protect yourself?" |
||||
}, |
||||
{ |
||||
"title": "Surveillance", |
||||
"content": "Would you act differently if you knew no one was watching?" |
||||
}, |
||||
{ |
||||
"title": "Inside the Wall", |
||||
"content": "Does your environment feel like a shield or a cage?" |
||||
}, |
||||
{ |
||||
"title": "Constriction", |
||||
"content": "What’s the biggest change you made just to adapt?" |
||||
}, |
||||
{ |
||||
"title": "Freedom", |
||||
"content": "What does \"freedom\" actually look like to you?" |
||||
}, |
||||
{ |
||||
"title": "Awakening", |
||||
"content": "Do you accept your \"factory settings\"?" |
||||
}, |
||||
{ |
||||
"title": "Lying Flat", |
||||
"content": "Are you resting to go further, or because the road has ended?" |
||||
}, |
||||
{ |
||||
"title": "Fighting Back", |
||||
"content": "When was the last time you spoke up or said \"no\"?" |
||||
}, |
||||
{ |
||||
"title": "Subjectivity", |
||||
"content": "Do you usually speak up, or just go with the flow?" |
||||
}, |
||||
{ |
||||
"title": "Undefined", |
||||
"content": "Describe yourself in three words without using your job title." |
||||
}, |
||||
{ |
||||
"title": "Being Yourself", |
||||
"content": "How much of your true self do you show in public?" |
||||
}, |
||||
{ |
||||
"title": "Finding Gaps", |
||||
"content": "How do you find a little breathing room in your busy life?" |
||||
}, |
||||
{ |
||||
"title": "Suffocating", |
||||
"content": "Which topics or places make you feel like you can't breathe?" |
||||
}, |
||||
{ |
||||
"title": "Who Gets It", |
||||
"content": "Who in your life truly understands what you’re going through?" |
||||
}, |
||||
{ |
||||
"title": "Anxiety", |
||||
"content": "Are you worried about the future, or stuck in the past?" |
||||
}, |
||||
{ |
||||
"title": "World-weary", |
||||
"content": "Is this feeling just pure fatigue from the way things are?" |
||||
}, |
||||
{ |
||||
"title": "Irony", |
||||
"content": "Have the rules you once hated become your survival instincts?" |
||||
}, |
||||
{ |
||||
"title": "Empathy", |
||||
"content": "Which words in this device said what you couldn't say out loud?" |
||||
}, |
||||
{ |
||||
"title": "Illusion", |
||||
"content": "Are your goals truly yours, or just what society wants for you?" |
||||
} |
||||
], |
||||
"zh": [ |
||||
{ |
||||
"title": "窮忙", |
||||
"content": "你今天的行程裡,有多少比例是為了自己?" |
||||
}, |
||||
{ |
||||
"title": "內耗", |
||||
"content": "你會花很多力氣處理自己的情緒嗎?" |
||||
}, |
||||
{ |
||||
"title": "學貸", |
||||
"content": "一張畢業證書要花多少年來分期付款?" |
||||
}, |
||||
{ |
||||
"title": "租屋", |
||||
"content": "這個月薪水的幾分之幾交給房東了?" |
||||
}, |
||||
{ |
||||
"title": "負債", |
||||
"content": "你覺得自己現在有欠下「時間」或「人情」嗎?" |
||||
}, |
||||
{ |
||||
"title": "成年", |
||||
"content": "法律定義的成年與你心理感受的成年,中間的距離有多少?" |
||||
}, |
||||
{ |
||||
"title": "求生存", |
||||
"content": "你會說你現在在過生活還是求生存?" |
||||
}, |
||||
{ |
||||
"title": "規訓", |
||||
"content": "你所遵守的社交習慣,哪些是天生的、哪些是後來學會的?" |
||||
}, |
||||
{ |
||||
"title": "PUA", |
||||
"content": "讓你自我懷疑的是事情本身,還是某人的評價?" |
||||
}, |
||||
{ |
||||
"title": "耗材", |
||||
"content": "如果你明天突然休息,系統真的會因此停擺嗎?" |
||||
}, |
||||
{ |
||||
"title": "奴性", |
||||
"content": "在面對不合理的指令時,你的第一反應通常是什麼?" |
||||
}, |
||||
{ |
||||
"title": "潛規則", |
||||
"content": "有哪些規則是沒被寫出來,但你卻正在默默遵守的?" |
||||
}, |
||||
{ |
||||
"title": "社畜", |
||||
"content": "你如何區分「工作時的你」與「下班後的你」?" |
||||
}, |
||||
{ |
||||
"title": "門禁", |
||||
"content": "覺得最舒適的回家時間是什麼時候?" |
||||
}, |
||||
{ |
||||
"title": "邊界", |
||||
"content": "你什麼時候會說不,來保護自己?" |
||||
}, |
||||
{ |
||||
"title": "監控", |
||||
"content": "如果沒人看見,你的行為表現會和現在有很大的差異嗎?" |
||||
}, |
||||
{ |
||||
"title": "牆內", |
||||
"content": "你覺得現在的環境帶給你的是「保護感」還是「限制感」?" |
||||
}, |
||||
{ |
||||
"title": "限縮", |
||||
"content": "為了適應環境,你做出的最大調整是什麼?" |
||||
}, |
||||
{ |
||||
"title": "自由", |
||||
"content": "對你來說,什麼樣的狀態才叫「自由」?" |
||||
}, |
||||
{ |
||||
"title": "覺醒", |
||||
"content": "你接受自己的出廠設定嗎?" |
||||
}, |
||||
{ |
||||
"title": "躺平", |
||||
"content": "休息是因為想走更長的路,還是因為路已經走不下去了?" |
||||
}, |
||||
{ |
||||
"title": "反擊", |
||||
"content": "你曾為什麼事,大聲說出不滿或拒絕?" |
||||
}, |
||||
{ |
||||
"title": "主體性", |
||||
"content": "在群體中,你通常是提出意見的人還是配合的人?" |
||||
}, |
||||
{ |
||||
"title": "不定義", |
||||
"content": "如果不使用職稱,你會用哪三個詞來描述自己?" |
||||
}, |
||||
{ |
||||
"title": "做自己", |
||||
"content": "在公共場所,你覺得自己有多少程度能呈現真實的樣貌?" |
||||
}, |
||||
{ |
||||
"title": "找縫隙", |
||||
"content": "在繁忙的節奏中,你最常用什麼方式找回自己的空間?" |
||||
}, |
||||
{ |
||||
"title": "窒息", |
||||
"content": "在什麼樣的環境或話題中,你會感到最明顯的壓迫感?" |
||||
}, |
||||
{ |
||||
"title": "誰懂", |
||||
"content": "你覺得目前身邊的人,誰真正理解你的處境?" |
||||
}, |
||||
{ |
||||
"title": "焦慮", |
||||
"content": "你在擔心還沒發生的未來,還是後悔已經發生的過去?" |
||||
}, |
||||
{ |
||||
"title": "厭世", |
||||
"content": "厭世是對現狀的疲憊嗎?" |
||||
}, |
||||
{ |
||||
"title": "諷刺", |
||||
"content": "在荒謬的日常中,你如何用幽默來回應生活的無奈?" |
||||
}, |
||||
{ |
||||
"title": "共感", |
||||
"content": "裝置中哪些話是你說不出口,但有人替你說了?" |
||||
}, |
||||
{ |
||||
"title": "幻覺", |
||||
"content": "你正在追求的事,有多少是你的願望,有多少是社會的投射?" |
||||
} |
||||
] |
||||
} |
||||
} |
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 547 B |
|
After Width: | Height: | Size: 226 B |
@ -0,0 +1,23 @@ |
||||
import { useState } from 'react' |
||||
import './App.css' |
||||
import {Button, LangButton} from './comps/button' |
||||
import { useText } from './utils/usetext.jsx' |
||||
import { useNavigate } from 'react-router-dom'; |
||||
|
||||
function App() { |
||||
|
||||
const { getText, nextLang, toggleLang } = useText(); |
||||
const navigate=useNavigate(); |
||||
|
||||
return ( |
||||
<main> |
||||
<LangButton lang={nextLang} onClick={toggleLang} /> |
||||
<img src="/main.png" alt="Main" className='w-full flex-1 object-contain'/> |
||||
<Button onClick={()=>{ |
||||
navigate('/keywords'); |
||||
}}>{getText('button_enter')}</Button> |
||||
</main> |
||||
) |
||||
} |
||||
|
||||
export default App |
||||
|
After Width: | Height: | Size: 4.0 KiB |
@ -0,0 +1,23 @@ |
||||
export function Button({ children, onClick, ...props }) { |
||||
return ( |
||||
<button |
||||
onClick={onClick} |
||||
className={`w-full bg-black text-white border border-white uppercase text-[1.25rem] ${props.className} disabled:border-[#939393] disabled:text-[#939393] disabled:bg-transparent`} |
||||
{...props} |
||||
> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
|
||||
export function LangButton({ lang, onClick }) { |
||||
return ( |
||||
<button |
||||
onClick={onClick} |
||||
className="bg-black text-white border border-white uppercase rounded-full text-[0.875rem] w-[1.875rem] aspect-square flex items-center justify-center absolute top-[1.5rem] right-[1.5rem]" |
||||
> |
||||
{lang} |
||||
</button> |
||||
); |
||||
} |
||||
@ -0,0 +1,225 @@ |
||||
import { use, useEffect, useRef } from "react"; |
||||
|
||||
|
||||
const StartCount=50; |
||||
const StartUpdateInterval=1000; |
||||
const PointCount=4; |
||||
const PointScale=0.7; |
||||
|
||||
export default function Canvas({ lookat, ...props }) { |
||||
|
||||
console.log('Canvas lookat', lookat); |
||||
|
||||
const refContainer = useRef(null); |
||||
const refCanvas = useRef(null); |
||||
const refContext = useRef(null); |
||||
const refDrawing = useRef(false); |
||||
|
||||
const refStars=useRef([]); |
||||
const refLastUpdate=useRef(Date.now()); |
||||
|
||||
|
||||
const refPoints=useRef([]); |
||||
const refLookat=useRef(lookat); |
||||
|
||||
|
||||
|
||||
function randomPointsInArc(baseangle){ |
||||
|
||||
const width=refCanvas.current.width; |
||||
const height=refCanvas.current.height; |
||||
|
||||
const radius_x=342/393*PointScale; |
||||
const radius_y=425/393*PointScale; |
||||
const rotate_angle=54*Math.PI/180; |
||||
|
||||
const angle=baseangle+(Math.random()-0.5)*Math.PI/2; |
||||
const scale=Math.random()*0.5+0.5; |
||||
|
||||
|
||||
|
||||
let x=Math.cos(angle)*radius_x*width/2*scale; |
||||
let y=Math.sin(angle)*radius_y*width/2*scale; |
||||
|
||||
// rotate |
||||
const rotated_x = x * Math.cos(rotate_angle) - y * Math.sin(rotate_angle); |
||||
const rotated_y = x * Math.sin(rotate_angle) + y * Math.cos(rotate_angle); |
||||
|
||||
// translate to center |
||||
const center_x=width/2; |
||||
const center_y=height/2; |
||||
|
||||
return {x: rotated_x + center_x, y: rotated_y + center_y}; |
||||
} |
||||
|
||||
function randomPoints(){ |
||||
const points=[]; |
||||
|
||||
for(let i=0; i<PointCount; i++){ |
||||
|
||||
const start_angle = Math.random()*Math.PI*2; |
||||
const radiation_fan = Math.random()*Math.PI+Math.PI/4; |
||||
const radius = Math.random()*10+5; |
||||
const {x, y}=randomPointsInArc(Math.PI*2.0/PointCount*i); |
||||
|
||||
points.push({ |
||||
x, |
||||
y, |
||||
radiations: Math.floor(Math.random()*4+2), |
||||
radius: radius, |
||||
start_angle: start_angle, |
||||
radiation_fan: radiation_fan, |
||||
velocity: {x: Math.random()*.5+.5, y: Math.random()*.5+.5, angle: (Math.random()-.5)*2.0}, |
||||
}); |
||||
} |
||||
refPoints.current=points; |
||||
} |
||||
|
||||
function movePoints(){ |
||||
const width=refCanvas.current.width; |
||||
const height=refCanvas.current.height; |
||||
refPoints.current.forEach(point => { |
||||
const angle=Date.now()/1000+point.start_angle; |
||||
const radius=point.radius; |
||||
point.x+=Math.cos(angle)*radius*width/10000*point.velocity.x; |
||||
point.y+=Math.sin(angle)*radius*width/10000*point.velocity.y; |
||||
point.start_angle+=0.01*point.velocity.angle; |
||||
}); |
||||
} |
||||
|
||||
|
||||
function randomStars(){ |
||||
const stars=[]; |
||||
for(let i=0; i<StartCount; i++){ |
||||
stars.push({ |
||||
x: Math.random(), |
||||
y: Math.random(), |
||||
alpha: Math.random(), |
||||
}); |
||||
} |
||||
refStars.current=stars; |
||||
} |
||||
|
||||
function draw(){ |
||||
|
||||
|
||||
// draw random circles |
||||
const context=refContext.current; |
||||
const canvas=refCanvas.current; |
||||
context.clearRect(0, 0, canvas.width, canvas.height); |
||||
|
||||
// draw random circles |
||||
refStars.current.forEach(star => { |
||||
const x = star.x * canvas.width; |
||||
const y = star.y * canvas.height; |
||||
const radius = 1; |
||||
const alpha = star.alpha; |
||||
context.beginPath(); |
||||
context.arc(x, y, radius, 0, Math.PI * 2); |
||||
context.fillStyle = `rgba(255, 255, 255, ${alpha})`; |
||||
context.fill(); |
||||
}); |
||||
|
||||
|
||||
// draw points |
||||
refPoints.current.forEach((point, index) => { |
||||
|
||||
if(index===refLookat.current){ |
||||
context.beginPath(); |
||||
context.arc(point.x, point.y, 5, 0, Math.PI * 2); |
||||
context.fillStyle = 'rgba(255, 255, 255, 1)'; |
||||
context.fill(); |
||||
|
||||
|
||||
context.beginPath(); |
||||
context.arc(point.x, point.y, 10, 0, Math.PI * 2); |
||||
context.strokeStyle = 'rgba(255, 255, 255, 1)'; |
||||
context.stroke(); |
||||
} |
||||
|
||||
// radiations around point |
||||
for(let i=0; i<point.radiations; i++){ |
||||
const angle = (i / point.radiations) * point.radiation_fan + point.start_angle; |
||||
const x = point.x + Math.cos(angle) * point.radius *(canvas.width/100); |
||||
const y = point.y + Math.sin(angle) * point.radius*(canvas.width/100); |
||||
context.beginPath(); |
||||
// context.arc(x, y, 1, 0, Math.PI * 2); |
||||
// context.fillStyle = 'rgba(255, 255, 255, 0.8)'; |
||||
// context.fill(); |
||||
|
||||
context.moveTo(point.x, point.y); |
||||
context.lineTo(x, y); |
||||
context.strokeStyle = 'rgba(255, 255, 255, 1)'; |
||||
context.stroke(); |
||||
} |
||||
|
||||
}); |
||||
// draw lines along with points |
||||
for(let i=0; i<refPoints.current.length-1; i++){ |
||||
|
||||
const pointA=refPoints.current[i]; |
||||
const pointB=refPoints.current[i+1]; |
||||
|
||||
const xA = pointA.x ; |
||||
const yA = pointA.y; |
||||
const xB = pointB.x ; |
||||
const yB = pointB.y ; |
||||
|
||||
context.beginPath(); |
||||
context.moveTo(xA, yA); |
||||
context.lineTo(xB, yB); |
||||
context.strokeStyle = 'rgba(255, 255, 255, 1)'; |
||||
context.stroke(); |
||||
|
||||
} |
||||
|
||||
|
||||
|
||||
movePoints(); |
||||
|
||||
if(Date.now()-refLastUpdate.current>StartUpdateInterval){ |
||||
randomStars(); |
||||
// randomPoints(); |
||||
refLastUpdate.current=Date.now(); |
||||
} |
||||
|
||||
refDrawing.current=requestAnimationFrame(draw); |
||||
} |
||||
|
||||
useEffect(()=>{ |
||||
refLookat.current=lookat; |
||||
}, [lookat]); |
||||
|
||||
useEffect(()=>{ |
||||
|
||||
// set canvas size to container size |
||||
const container=refContainer.current; |
||||
const canvas=refCanvas.current; |
||||
canvas.width=container.clientWidth; |
||||
canvas.height=container.clientHeight; |
||||
|
||||
const context=canvas.getContext('2d'); |
||||
refContext.current=context; |
||||
|
||||
randomStars(); |
||||
randomPoints(); |
||||
|
||||
refDrawing.current=requestAnimationFrame(draw); |
||||
|
||||
|
||||
return () => { |
||||
if(refDrawing.current){ |
||||
cancelAnimationFrame(refDrawing.current); |
||||
} |
||||
}; |
||||
|
||||
},[]); |
||||
|
||||
|
||||
return ( |
||||
<div ref={refContainer} {...props}> |
||||
<canvas ref={refCanvas} className="w-full h-full"></canvas> |
||||
</div> |
||||
) |
||||
|
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
@import "tailwindcss"; |
||||
|
||||
|
||||
body{ |
||||
font-family: "Space Mono", "Noto Sans TC", sans-serif; |
||||
} |
||||
.root{ |
||||
@apply bg-black text-white min-h-screen flex justify-center items-center; |
||||
|
||||
} |
||||
main{ |
||||
@apply bg-black text-white flex justify-center items-center min-h-screen px-[1rem] py-[3.5rem] flex flex-col justify-end gap-[3rem]; |
||||
} |
||||
|
||||
button{ |
||||
font-family: "Space Mono", "Noto Sans TC", sans-serif; |
||||
} |
||||
|
||||
h1{ |
||||
@apply text-[1.125rem] leading-[1.375rem] uppercase; |
||||
} |
||||
p{ |
||||
@apply text-[0.75rem] leading-[1.25rem]; |
||||
} |
||||
@ -0,0 +1,35 @@ |
||||
import { StrictMode } from 'react' |
||||
import { createRoot } from 'react-dom/client' |
||||
import './index.css' |
||||
import App from './App.jsx' |
||||
import Keywords from './pages/keywords.jsx' |
||||
import About from './pages/about.jsx' |
||||
import Explore from './pages/explore.jsx' |
||||
|
||||
import { createBrowserRouter, createHashRouter } from "react-router"; |
||||
import { RouterProvider } from "react-router/dom"; |
||||
import { TextProvider } from './utils/usetext.jsx' |
||||
|
||||
|
||||
const router = createBrowserRouter([ |
||||
{ |
||||
path: "/", |
||||
element: <App />, |
||||
}, |
||||
{ |
||||
path: '/keywords', |
||||
element: <Keywords />, |
||||
}, |
||||
{ |
||||
path: '/explore', |
||||
element: <Explore />, |
||||
} |
||||
]); |
||||
|
||||
createRoot(document.getElementById('root')).render( |
||||
<StrictMode> |
||||
<TextProvider> |
||||
<RouterProvider router={router} /> |
||||
</TextProvider> |
||||
</StrictMode>, |
||||
) |
||||
@ -0,0 +1,25 @@ |
||||
import { useText } from '../utils/usetext.jsx'; |
||||
|
||||
export default function About() { |
||||
const { getText } = useText(); |
||||
|
||||
return ( |
||||
<main className="z-10 fixed top-0 left-0 right-0 bg-black text-white min-h-screen flex flex-col !justify-start !items-stretch !gap-[2.63rem]"> |
||||
<div className='flex flex-col gap-[1.38rem]'> |
||||
<h1>{getText('title_about')}</h1> |
||||
<p>{getText('content_about')}</p> |
||||
</div> |
||||
<div className='flex flex-col gap-[1.38rem]'> |
||||
<h1>{getText('title_credits')}</h1> |
||||
<p>{getText('content_credits')}</p> |
||||
</div> |
||||
<div className='flex flex-col gap-[1.38rem]'> |
||||
<h1>{getText('title_share')}</h1> |
||||
<span className='flex flex-row gap-[0.5rem] items-center'> |
||||
<span className='p-[0.06rem]'><img className='w-[1rem]' src="letter.svg "/></span> |
||||
<a className='underline' href="mailto:contact@ultracombos.com">contact@ultracombos.com</a> |
||||
</span> |
||||
</div> |
||||
</main> |
||||
); |
||||
} |
||||
@ -0,0 +1,92 @@ |
||||
import { useEffect, useState } from "react"; |
||||
import { Button } from "../comps/button"; |
||||
import { useText } from "../utils/usetext.jsx"; |
||||
import { useNavigate } from "react-router-dom"; |
||||
import About from "./about.jsx"; |
||||
import Canvas from "../comps/canvas.jsx"; |
||||
|
||||
|
||||
const State={ |
||||
Intro: 'Intro', |
||||
Explore: 'Explore', |
||||
} |
||||
|
||||
export default function Explore() { |
||||
|
||||
const { selectedKeyword, getText } = useText(); |
||||
const navigate=useNavigate(); |
||||
|
||||
const [state, setState] = useState(State.Intro); |
||||
const [lookat, setLookat] = useState(selectedKeyword[0] || null); |
||||
const [showInfo, setShowInfo] = useState(false); |
||||
|
||||
useEffect(()=>{ |
||||
const timeoutId = setTimeout(()=>{ |
||||
setState(State.Explore); |
||||
}, 2000); |
||||
|
||||
return () => { |
||||
clearTimeout(timeoutId); |
||||
}; |
||||
|
||||
},[]); |
||||
|
||||
if(state===State.Intro){ |
||||
return ( |
||||
<main className="!justify-center"> |
||||
<div className="text-[1.125rem]">{getText('title_explore')}</div> |
||||
<img src="hint.svg"/> |
||||
</main> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<main className="!px-0 !gap-0 !justify-start"> |
||||
<section className="absolute top-[1.5rem] left-[1.5rem] right-[1.5rem] flex flex-row justify-between items-center"> |
||||
<button className="uppercase flex flex-row" onClick={()=>{ |
||||
navigate('/'); |
||||
}}><img src="back.svg" alt="back"/>{getText('button_reselect')}</button> |
||||
|
||||
<button className="z-20 w-[1.875rem] border aspect-square flex justify-center items-center rounded-full" |
||||
onClick={()=>{ |
||||
setShowInfo(!showInfo); |
||||
}}>{!showInfo?'i':<img src="x.svg" alt="close"/>}</button> |
||||
</section> |
||||
|
||||
<div className="w-full relative aspect-[393/500]"> |
||||
<img src="egg_bg.svg" alt="egg" className="w-full object-contain"/> |
||||
<Canvas className="absolute inset-0 top-0 left-0 w-full h-full" |
||||
lookat={selectedKeyword.indexOf(lookat)}/> |
||||
</div> |
||||
|
||||
<div className="flex flex-row gap-[0.75rem] justify-center items-center mt-[1.5rem]"> |
||||
<img src="eye.svg" alt="eye" className="w-[0.9965rem] object-contain"/> |
||||
<div className="text-[0.75rem]">{getText('title_observe')}</div> |
||||
</div> |
||||
<section className="w-full px-[1.75rem] flex flex-col mt-[1.44rem]"> |
||||
<div className="w-full overflow-x-auto py-[1rem]"> |
||||
<div className="flex flex-row gap-[0.75rem]"> |
||||
{selectedKeyword.map((keyword, index) => ( |
||||
<div key={index} className="w-full flex justify-center items-center border border-white" |
||||
style={{ |
||||
backgroundColor: lookat && lookat.title===keyword.title ? '#939393' : 'transparent', |
||||
// color: lookat && lookat.title===keyword.title ? 'black' : 'white', |
||||
}} |
||||
onClick={()=>setLookat(keyword)}> |
||||
<span className="text-center text-nowrap text-[0.6875rem] px-[0.97rem] py-[0.47rem]">{keyword.title}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
<div className="w-full py-[0.5rem]"> |
||||
{lookat && ( |
||||
<div className="text-[0.875rem] tracking-[0.0875rem] leading-[160%]">{lookat.content}</div> |
||||
)} |
||||
</div> |
||||
</section> |
||||
</main> |
||||
{showInfo && <About/>} |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,62 @@ |
||||
import { useEffect, useRef, useState } from 'react' |
||||
import {Button} from "../comps/button"; |
||||
import { useText } from "../utils/usetext.jsx"; |
||||
import { useNavigate } from "react-router-dom"; |
||||
|
||||
export default function Keywords() { |
||||
|
||||
const { getText, getKeywords, selectedKeyword, select } = useText(); |
||||
const keywords = getKeywords(); |
||||
const rowCount=3; |
||||
const colCount = Math.ceil(keywords.length / rowCount); |
||||
|
||||
const refContainer=useRef(); |
||||
const navigate=useNavigate(); |
||||
|
||||
|
||||
useEffect(()=>{ |
||||
const container=refContainer.current; |
||||
// scroll to center |
||||
container.scrollLeft=(container.scrollWidth-container.clientWidth)/2; |
||||
|
||||
return () => { |
||||
|
||||
}; |
||||
|
||||
}, [keywords]); |
||||
|
||||
|
||||
|
||||
return ( |
||||
<main className="!px-0 !justify-between"> |
||||
<h1 className="text-[1.125rem] mt-[3.875rem]">{getText('title_keywords')}</h1> |
||||
|
||||
<section ref={refContainer} className="w-full h-[107vw] overflow-x-auto overflow-y-hidden relative"> |
||||
{keywords.map((keyword, index) => ( |
||||
<div key={index} className="absolute w-[54vw] aspect-[212/180] bg-contain bg-no-repeat flex items-center justify-center" |
||||
style={{ |
||||
top: `${Math.floor(index/colCount) * 32}vw`, |
||||
left: `${(index % colCount) * 40 + 5*Math.floor(index/colCount)}vw`, |
||||
backgroundImage: `url(/topic${selectedKeyword.includes(keyword) ? '_select' : ''}.svg)`, |
||||
}} |
||||
onClick={()=>{ |
||||
console.log('clicked keyword', keyword.title); |
||||
select(keyword); |
||||
}}> |
||||
<span className="break-word max-w-[50%] text-center">{keyword.title}</span> |
||||
</div> |
||||
))} |
||||
</section> |
||||
|
||||
<div className="w-full px-[1.5rem]"> |
||||
<Button onClick={()=>{ |
||||
navigate('/explore'); |
||||
}} |
||||
disabled={selectedKeyword.length<4} |
||||
> |
||||
{getText('button_explore')} |
||||
</Button> |
||||
</div> |
||||
</main> |
||||
); |
||||
} |
||||
@ -0,0 +1,67 @@ |
||||
import { createContext, useContext, useEffect, useState } from "react"; |
||||
import useSWR from "swr"; |
||||
|
||||
const TextContent=createContext(); |
||||
|
||||
|
||||
const LANG={ |
||||
"en":"EN", |
||||
"zh":"中" |
||||
}; |
||||
|
||||
export function TextProvider({children}) { |
||||
|
||||
const { data }=useSWR("/data.json", async (url)=>{ |
||||
const res=await fetch(url); |
||||
return res.json(); |
||||
}); |
||||
|
||||
const [lang, setLang]=useState("en"); |
||||
const [nextLang, setNextLang]=useState("zh"); |
||||
|
||||
const [selectedKeyword, setSelectedKeyword] = useState([]); |
||||
|
||||
function getText(key) { |
||||
// console.log("getText", key, lang, data?.[key]?.[lang]); |
||||
return data?.[key]?.[lang] || ""; |
||||
} |
||||
|
||||
function toggleLang() { |
||||
const currentIndex=Object.keys(LANG).indexOf(lang); |
||||
const nextIndex=(currentIndex+1)%Object.keys(LANG).length; |
||||
setLang(Object.keys(LANG)[nextIndex]); |
||||
setNextLang(Object.values(LANG)[currentIndex]); |
||||
} |
||||
|
||||
function getKeywords(){ |
||||
return data?.keywords?.[lang] || []; |
||||
} |
||||
|
||||
function select(select){ |
||||
if(selectedKeyword.includes(select)){ |
||||
setSelectedKeyword(selectedKeyword.filter(item=>item!==select)); |
||||
}else{ |
||||
if(selectedKeyword.length>=4){ |
||||
// remove the first one, insert the new one at the end |
||||
setSelectedKeyword([...selectedKeyword.slice(1), select]); |
||||
}else{ |
||||
setSelectedKeyword([...selectedKeyword, select]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
useEffect(()=>{ |
||||
console.log("selectedKeyword changed", selectedKeyword); |
||||
}, [selectedKeyword]); |
||||
|
||||
|
||||
return ( |
||||
<TextContent.Provider value={{ data, getText, nextLang, toggleLang, getKeywords, selectedKeyword, select }}> |
||||
{children} |
||||
</TextContent.Provider> |
||||
) |
||||
} |
||||
|
||||
export function useText() { |
||||
return useContext(TextContent); |
||||
} |
||||
@ -0,0 +1,7 @@ |
||||
import { defineConfig } from 'vite' |
||||
import react from '@vitejs/plugin-react' |
||||
import tailwindcss from '@tailwindcss/vite' |
||||
// https://vite.dev/config/
|
||||
export default defineConfig({ |
||||
plugins: [react(), tailwindcss()], |
||||
}) |
||||