git-city-3d-github-visualization
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGit City — 3D GitHub Profile Visualization
Git City — 3D GitHub个人资料可视化
Skill by ara.so — Daily 2026 Skills collection.
Git City transforms GitHub profiles into a 3D pixel art city. Each user becomes a unique building: height from contributions, width from repos, window brightness from stars. Built with Next.js 16 (App Router), React Three Fiber, and Supabase.
来自ara.so的技能——2026每日技能合集。
Git City 将GitHub个人资料转化为3D像素艺术城市。每个用户对应一座独特的建筑:高度由贡献数决定,宽度由仓库数决定,窗户亮度由获得的星标数决定。基于Next.js 16(App Router)、React Three Fiber和Supabase构建。
Quick Setup
快速设置
bash
git clone https://github.com/srizzon/git-city.git
cd git-city
npm installbash
git clone https://github.com/srizzon/git-city.git
cd git-city
npm installCopy env template
复制环境变量模板
cp .env.example .env.local # Linux/macOS
copy .env.example .env.local # Windows CMD
Copy-Item .env.example .env.local # PowerShell
npm run dev
cp .env.example .env.local # Linux/macOS
copy .env.example .env.local # Windows CMD
Copy-Item .env.example .env.local # PowerShell
npm run dev
undefinedundefinedEnvironment Variables
环境变量
Fill in after copying:
.env.localbash
undefined复制后填写:
.env.localbash
undefinedSupabase — Project Settings → API
Supabase — 项目设置 → API
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
GitHub — Settings → Developer settings → Personal access tokens
GitHub — 设置 → 开发者设置 → 个人访问令牌
GITHUB_TOKEN=github_pat_your_token_here
GITHUB_TOKEN=github_pat_your_token_here
Optional: comma-separated GitHub logins for /admin/ads access
可选:逗号分隔的GitHub登录名,用于/admin/ads访问
ADMIN_GITHUB_LOGINS=your_github_login
**Finding Supabase values:** Dashboard → Project Settings → API
**Finding GitHub token:** github.com → Settings → Developer settings → Personal access tokens (fine-grained recommended)ADMIN_GITHUB_LOGINS=your_github_login
**查找Supabase值:** 控制台 → 项目设置 → API
**查找GitHub令牌:** github.com → 设置 → 开发者设置 → 个人访问令牌(推荐使用细粒度令牌)Project Structure
项目结构
git-city/
├── app/ # Next.js App Router pages
│ ├── page.tsx # Main city view
│ ├── [username]/ # User profile pages
│ ├── compare/ # Side-by-side compare mode
│ └── admin/ # Admin panel
├── components/
│ ├── city/ # 3D city scene components
│ │ ├── Building.tsx # Individual building mesh
│ │ ├── CityScene.tsx # Main R3F canvas/scene
│ │ └── LODManager.tsx # Level-of-detail system
│ ├── ui/ # 2D overlay UI components
│ └── profile/ # Profile page components
├── lib/
│ ├── github.ts # GitHub API helpers
│ ├── supabase/ # Supabase client + server utils
│ ├── buildings.ts # Building metric calculations
│ └── achievements.ts # Achievement logic
├── hooks/ # Custom React hooks
├── types/ # TypeScript type definitions
└── public/ # Static assetsgit-city/
├── app/ # Next.js App Router页面
│ ├── page.tsx # 主城市视图
│ ├── [username]/ # 用户个人资料页面
│ ├── compare/ # 并排对比模式
│ └── admin/ # 管理面板
├── components/
│ ├── city/ # 3D城市场景组件
│ │ ├── Building.tsx # 单个建筑网格
│ │ ├── CityScene.tsx # 主R3F画布/场景
│ │ └── LODManager.tsx # 细节层次系统
│ ├── ui/ # 2D覆盖UI组件
│ └── profile/ # 个人资料页面组件
├── lib/
│ ├── github.ts # GitHub API工具函数
│ ├── supabase/ # Supabase客户端+服务端工具
│ ├── buildings.ts # 建筑指标计算
│ └── achievements.ts # 成就逻辑
├── hooks/ # 自定义React钩子
├── types/ # TypeScript类型定义
└── public/ # 静态资源Core Concepts
核心概念
Building Metrics Mapping
建筑指标映射
Buildings are generated from GitHub profile data:
typescript
// lib/buildings.ts pattern
interface BuildingMetrics {
height: number; // Based on total contributions
width: number; // Based on public repo count
windowBrightness: number; // Based on total stars received
windowPattern: number[]; // Based on recent activity pattern
}
function calculateBuildingMetrics(profile: GitHubProfile): BuildingMetrics {
const height = Math.log10(profile.totalContributions + 1) * 10;
const width = Math.min(Math.ceil(profile.publicRepos / 10), 8);
const windowBrightness = Math.min(profile.totalStars / 1000, 1);
return { height, width, windowBrightness, windowPattern: [] };
}建筑由GitHub个人资料数据生成:
typescript
// lib/buildings.ts 模式
interface BuildingMetrics {
height: number; // 基于总贡献数
width: number; // 基于公共仓库数量
windowBrightness: number; // 基于获得的总星标数
windowPattern: number[]; // 基于近期活动模式
}
function calculateBuildingMetrics(profile: GitHubProfile): BuildingMetrics {
const height = Math.log10(profile.totalContributions + 1) * 10;
const width = Math.min(Math.ceil(profile.publicRepos / 10), 8);
const windowBrightness = Math.min(profile.totalStars / 1000, 1);
return { height, width, windowBrightness, windowPattern: [] };
}3D Building Component (React Three Fiber)
3D建筑组件(React Three Fiber)
tsx
// components/city/Building.tsx pattern
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface BuildingProps {
position: [number, number, number];
metrics: BuildingMetrics;
username: string;
isSelected?: boolean;
onClick?: () => void;
}
export function Building({ position, metrics, username, isSelected, onClick }: BuildingProps) {
const meshRef = useRef<THREE.Mesh>(null);
// Animate selected building
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime) * 0.05;
}
});
return (
<group position={position} onClick={onClick}>
{/* Main building body */}
<mesh ref={meshRef}>
<boxGeometry args={[metrics.width, metrics.height, metrics.width]} />
<meshStandardMaterial color="#1a1a2e" />
</mesh>
{/* Windows as instanced meshes for performance */}
<WindowInstances metrics={metrics} />
</group>
);
}tsx
// components/city/Building.tsx 模式
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
interface BuildingProps {
position: [number, number, number];
metrics: BuildingMetrics;
username: string;
isSelected?: boolean;
onClick?: () => void;
}
export function Building({ position, metrics, username, isSelected, onClick }: BuildingProps) {
const meshRef = useRef<THREE.Mesh>(null);
// 为选中的建筑添加动画
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime) * 0.05;
}
});
return (
<group position={position} onClick={onClick}>
{/* 建筑主体 */}
<mesh ref={meshRef}>
<boxGeometry args={[metrics.width, metrics.height, metrics.width]} />
<meshStandardMaterial color="#1a1a2e" />
</mesh>
{/* 使用实例化网格实现窗户以提升性能 */}
<WindowInstances metrics={metrics} />
</group>
);
}Instanced Meshes for Performance
实例化网格提升性能
Git City uses instanced rendering for windows — critical for a city with many buildings:
tsx
// components/city/WindowInstances.tsx pattern
import { useRef, useEffect } from 'react';
import { InstancedMesh, Matrix4, Color } from 'three';
export function WindowInstances({ metrics }: { metrics: BuildingMetrics }) {
const meshRef = useRef<InstancedMesh>(null);
useEffect(() => {
if (!meshRef.current) return;
const matrix = new Matrix4();
const color = new Color();
let index = 0;
// Calculate window positions based on building dimensions
for (let floor = 0; floor < metrics.height; floor++) {
for (let col = 0; col < metrics.width; col++) {
const isLit = metrics.windowPattern[index] > 0.5;
matrix.setPosition(col * 1.1 - metrics.width / 2, floor * 1.2, 0.51);
meshRef.current.setMatrixAt(index, matrix);
meshRef.current.setColorAt(
index,
color.set(isLit ? '#FFD700' : '#1a1a2e')
);
index++;
}
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}, [metrics]);
const windowCount = Math.floor(metrics.height) * metrics.width;
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, windowCount]}>
<planeGeometry args={[0.4, 0.5]} />
<meshBasicMaterial />
</instancedMesh>
);
}Git City 使用实例化渲染实现窗户——这对拥有大量建筑的城市至关重要:
tsx
// components/city/WindowInstances.tsx 模式
import { useRef, useEffect } from 'react';
import { InstancedMesh, Matrix4, Color } from 'three';
export function WindowInstances({ metrics }: { metrics: BuildingMetrics }) {
const meshRef = useRef<InstancedMesh>(null);
useEffect(() => {
if (!meshRef.current) return;
const matrix = new Matrix4();
const color = new Color();
let index = 0;
// 根据建筑尺寸计算窗户位置
for (let floor = 0; floor < metrics.height; floor++) {
for (let col = 0; col < metrics.width; col++) {
const isLit = metrics.windowPattern[index] > 0.5;
matrix.setPosition(col * 1.1 - metrics.width / 2, floor * 1.2, 0.51);
meshRef.current.setMatrixAt(index, matrix);
meshRef.current.setColorAt(
index,
color.set(isLit ? '#FFD700' : '#1a1a2e')
);
index++;
}
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}, [metrics]);
const windowCount = Math.floor(metrics.height) * metrics.width;
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, windowCount]}>
<planeGeometry args={[0.4, 0.5]} />
<meshBasicMaterial />
</instancedMesh>
);
}GitHub API Integration
GitHub API集成
typescript
// lib/github.ts pattern
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function fetchGitHubProfile(username: string) {
const [userResponse, reposResponse] = await Promise.all([
octokit.users.getByUsername({ username }),
octokit.repos.listForUser({ username, per_page: 100, sort: 'updated' }),
]);
const totalStars = reposResponse.data.reduce(
(sum, repo) => sum + (repo.stargazers_count ?? 0),
0
);
return {
username: userResponse.data.login,
avatarUrl: userResponse.data.avatar_url,
publicRepos: userResponse.data.public_repos,
followers: userResponse.data.followers,
totalStars,
};
}
export async function fetchContributionData(username: string): Promise<number> {
// Use GitHub GraphQL for contribution calendar data
const query = `
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}
`;
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { username } }),
});
const data = await response.json();
return data.data.user.contributionsCollection.contributionCalendar.totalContributions;
}typescript
// lib/github.ts 模式
import { Octokit } from '@octokit/rest';
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export async function fetchGitHubProfile(username: string) {
const [userResponse, reposResponse] = await Promise.all([
octokit.users.getByUsername({ username }),
octokit.repos.listForUser({ username, per_page: 100, sort: 'updated' }),
]);
const totalStars = reposResponse.data.reduce(
(sum, repo) => sum + (repo.stargazers_count ?? 0),
0
);
return {
username: userResponse.data.login,
avatarUrl: userResponse.data.avatar_url,
publicRepos: userResponse.data.public_repos,
followers: userResponse.data.followers,
totalStars,
};
}
export async function fetchContributionData(username: string): Promise<number> {
// 使用GitHub GraphQL获取贡献日历数据
const query = `
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}
`;
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { username } }),
});
const data = await response.json();
return data.data.user.contributionsCollection.contributionCalendar.totalContributions;
}Supabase Integration
Supabase集成
typescript
// lib/supabase/server.ts pattern — server-side client
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}
// lib/supabase/client.ts — browser client
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}typescript
// lib/supabase/server.ts 模式 — 服务端客户端
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}
// lib/supabase/client.ts — 浏览器客户端
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Achievement System
成就系统
typescript
// lib/achievements.ts pattern
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: UserStats) => boolean;
}
export const ACHIEVEMENTS: Achievement[] = [
{
id: 'first-commit',
name: 'First Commit',
description: 'Made your first contribution',
icon: '🌱',
condition: (stats) => stats.totalContributions >= 1,
},
{
id: 'thousand-commits',
name: 'Commit Crusher',
description: '1,000+ total contributions',
icon: '⚡',
condition: (stats) => stats.totalContributions >= 1000,
},
{
id: 'star-collector',
name: 'Star Collector',
description: 'Earned 100+ stars across repos',
icon: '⭐',
condition: (stats) => stats.totalStars >= 100,
},
{
id: 'open-sourcer',
name: 'Open Sourcer',
description: '20+ public repositories',
icon: '📦',
condition: (stats) => stats.publicRepos >= 20,
},
];
export function calculateAchievements(stats: UserStats): Achievement[] {
return ACHIEVEMENTS.filter((achievement) => achievement.condition(stats));
}typescript
// lib/achievements.ts 模式
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (stats: UserStats) => boolean;
}
export const ACHIEVEMENTS: Achievement[] = [
{
id: 'first-commit',
name: 'First Commit',
description: '完成首次贡献',
icon: '🌱',
condition: (stats) => stats.totalContributions >= 1,
},
{
id: 'thousand-commits',
name: 'Commit Crusher',
description: '总贡献数超过1000',
icon: '⚡',
condition: (stats) => stats.totalContributions >= 1000,
},
{
id: 'star-collector',
name: 'Star Collector',
description: '仓库总星标数超过100',
icon: '⭐',
condition: (stats) => stats.totalStars >= 100,
},
{
id: 'open-sourcer',
name: 'Open Sourcer',
description: '拥有20个以上公共仓库',
icon: '📦',
condition: (stats) => stats.publicRepos >= 20,
},
];
export function calculateAchievements(stats: UserStats): Achievement[] {
return ACHIEVEMENTS.filter((achievement) => achievement.condition(stats));
}Adding a New Building Decoration
添加新建筑装饰
typescript
// types/decorations.ts
export type DecorationSlot = 'crown' | 'aura' | 'roof' | 'face';
export interface Decoration {
id: string;
slot: DecorationSlot;
name: string;
price: number;
component: React.ComponentType<DecorationProps>;
}
// components/city/decorations/Crown.tsx
export function CrownDecoration({ position, buildingWidth }: DecorationProps) {
return (
<group position={[position[0], position[1], position[2]]}>
<mesh>
<coneGeometry args={[buildingWidth / 3, 2, 4]} />
<meshStandardMaterial color="#FFD700" metalness={0.8} roughness={0.2} />
</mesh>
</group>
);
}
// Register in decoration registry
export const DECORATIONS: Decoration[] = [
{
id: 'golden-crown',
slot: 'crown',
name: 'Golden Crown',
price: 500,
component: CrownDecoration,
},
];typescript
// types/decorations.ts
export type DecorationSlot = 'crown' | 'aura' | 'roof' | 'face';
export interface Decoration {
id: string;
slot: DecorationSlot;
name: string;
price: number;
component: React.ComponentType<DecorationProps>;
}
// components/city/decorations/Crown.tsx
export function CrownDecoration({ position, buildingWidth }: DecorationProps) {
return (
<group position={[position[0], position[1], position[2]]}>
<mesh>
<coneGeometry args={[buildingWidth / 3, 2, 4]} />
<meshStandardMaterial color="#FFD700" metalness={0.8} roughness={0.2} />
</mesh>
</group>
);
}
// 在装饰注册表中注册
export const DECORATIONS: Decoration[] = [
{
id: 'golden-crown',
slot: 'crown',
name: 'Golden Crown',
price: 500,
component: CrownDecoration,
},
];Camera / Flight Controls
相机/飞行控制
tsx
// components/city/CameraController.tsx pattern
import { useThree, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
export function CameraController() {
const { camera } = useThree();
const targetRef = useRef(new THREE.Vector3());
const velocityRef = useRef(new THREE.Vector3());
useFrame((_, delta) => {
// Smooth lerp camera toward target
camera.position.lerp(targetRef.current, delta * 2);
});
// Expose flyTo function via context or ref
const flyTo = (position: THREE.Vector3) => {
targetRef.current.copy(position).add(new THREE.Vector3(0, 10, 20));
};
return null;
}tsx
// components/city/CameraController.tsx 模式
import { useThree, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
export function CameraController() {
const { camera } = useThree();
const targetRef = useRef(new THREE.Vector3());
const velocityRef = useRef(new THREE.Vector3());
useFrame((_, delta) => {
// 平滑移动相机到目标位置
camera.position.lerp(targetRef.current, delta * 2);
});
// 通过上下文或ref暴露flyTo函数
const flyTo = (position: THREE.Vector3) => {
targetRef.current.copy(position).add(new THREE.Vector3(0, 10, 20));
};
return null;
}Server Actions (Next.js App Router)
服务端操作(Next.js App Router)
typescript
// app/actions/kudos.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export async function sendKudos(toUsername: string) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Must be logged in to send kudos');
const { error } = await supabase.from('kudos').insert({
from_user_id: user.id,
to_username: toUsername,
created_at: new Date().toISOString(),
});
if (error) throw error;
revalidatePath(`/${toUsername}`);
}typescript
// app/actions/kudos.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export async function sendKudos(toUsername: string) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('必须登录才能发送点赞');
const { error } = await supabase.from('kudos').insert({
from_user_id: user.id,
to_username: toUsername,
created_at: new Date().toISOString(),
});
if (error) throw error;
revalidatePath(`/${toUsername}`);
}Profile Page Route
个人资料页面路由
tsx
// app/[username]/page.tsx pattern
import { fetchGitHubProfile } from '@/lib/github';
import { createClient } from '@/lib/supabase/server';
import { calculateAchievements } from '@/lib/achievements';
import { BuildingPreview } from '@/components/profile/BuildingPreview';
interface Props {
params: { username: string };
}
export default async function ProfilePage({ params }: Props) {
const { username } = params;
const [githubProfile, supabase] = await Promise.all([
fetchGitHubProfile(username),
createClient(),
]);
const { data: cityProfile } = await supabase
.from('profiles')
.select('*, decorations(*)')
.eq('username', username)
.single();
const achievements = calculateAchievements({
totalContributions: githubProfile.totalContributions,
totalStars: githubProfile.totalStars,
publicRepos: githubProfile.publicRepos,
});
return (
<main>
<BuildingPreview profile={githubProfile} cityProfile={cityProfile} />
<AchievementGrid achievements={achievements} />
</main>
);
}
export async function generateMetadata({ params }: Props) {
return {
title: `${params.username} — Git City`,
description: `View ${params.username}'s building in Git City`,
openGraph: {
images: [`/api/og/${params.username}`],
},
};
}tsx
// app/[username]/page.tsx 模式
import { fetchGitHubProfile } from '@/lib/github';
import { createClient } from '@/lib/supabase/server';
import { calculateAchievements } from '@/lib/achievements';
import { BuildingPreview } from '@/components/profile/BuildingPreview';
interface Props {
params: { username: string };
}
export default async function ProfilePage({ params }: Props) {
const { username } = params;
const [githubProfile, supabase] = await Promise.all([
fetchGitHubProfile(username),
createClient(),
]);
const { data: cityProfile } = await supabase
.from('profiles')
.select('*, decorations(*)')
.eq('username', username)
.single();
const achievements = calculateAchievements({
totalContributions: githubProfile.totalContributions,
totalStars: githubProfile.totalStars,
publicRepos: githubProfile.publicRepos,
});
return (
<main>
<BuildingPreview profile={githubProfile} cityProfile={cityProfile} />
<AchievementGrid achievements={achievements} />
</main>
);
}
export async function generateMetadata({ params }: Props) {
return {
title: `${params.username} — Git City`,
description: `查看${params.username}在Git City中的建筑`,
openGraph: {
images: [`/api/og/${params.username}`],
},
};
}Common Development Patterns
常见开发模式
LOD (Level of Detail) System
LOD(细节层次)系统
typescript
// Simplified LOD pattern used in the city
import { useThree } from '@react-three/fiber';
export function useLOD(buildingPosition: THREE.Vector3) {
const { camera } = useThree();
const distance = camera.position.distanceTo(buildingPosition);
if (distance < 50) return 'high'; // Full detail + animated windows
if (distance < 150) return 'medium'; // Simplified windows
return 'low'; // Box only
}typescript
// 城市中使用的简化LOD模式
import { useThree } from '@react-three/fiber';
export function useLOD(buildingPosition: THREE.Vector3) {
const { camera } = useThree();
const distance = camera.position.distanceTo(buildingPosition);
if (distance < 50) return 'high'; // 完整细节+动画窗户
if (distance < 150) return 'medium'; // 简化窗户
return 'low'; // 仅显示立方体
}Fetching with SWR in City View
在城市视图中使用SWR获取数据
tsx
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function useCityBuildings() {
const { data, error, isLoading } = useSWR('/api/buildings', fetcher, {
refreshInterval: 30000, // refresh every 30s for live activity feed
});
return { buildings: data, error, isLoading };
}tsx
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function useCityBuildings() {
const { data, error, isLoading } = useSWR('/api/buildings', fetcher, {
refreshInterval: 30000, // 每30秒刷新一次以获取实时活动
});
return { buildings: data, error, isLoading };
}Key API Routes
核心API路由
| Route | Purpose |
|---|---|
| Fetch all city buildings with positions |
| GitHub + city profile data |
| Send kudos to a user |
| Generate OG share card image |
| Stripe payment webhook |
| Admin panel (requires |
| 路由 | 用途 |
|---|---|
| 获取所有城市建筑及其位置 |
| GitHub+城市个人资料数据 |
| 向用户发送点赞 |
| 生成OG分享卡片图片 |
| Stripe支付Webhook |
| 管理面板(需要ADMIN_GITHUB_LOGINS权限) |
Troubleshooting
故障排除
3D scene not rendering
Check that and versions are compatible. The canvas needs a height set on its container div.
Check that
@react-three/fiberthreeGitHub API rate limits
Use a fine-grained token with appropriate scopes. The app caches GitHub responses in Supabase to avoid repeated API calls.
Use a fine-grained token with appropriate scopes. The app caches GitHub responses in Supabase to avoid repeated API calls.
Supabase auth not working locally
Configure the GitHub OAuth provider in your Supabase project and ensure your local callback URL () is allowlisted.
Configure the GitHub OAuth provider in your Supabase project and ensure your local callback URL (
http://localhost:3001/auth/callbackBuildings not appearing
Check that Supabase Row Level Security policies allow reads on the and tables for anonymous users.
Check that Supabase Row Level Security policies allow reads on the
profilesbuildingsWindow shimmer/flicker
This is usually a Z-fighting issue. Add a tiny offset () to window mesh positions along the normal axis.
This is usually a Z-fighting issue. Add a tiny offset (
0.001Performance issues with many buildings
Ensure instanced meshes are used for windows and the LOD system is active. Avoid creating new instances inside render loops — define them outside components or use .
Ensure instanced meshes are used for windows and the LOD system is active. Avoid creating new
THREE.MaterialuseMemo3D场景无法渲染
检查和版本是否兼容。画布的容器div需要设置高度。
检查
@react-three/fiberthreeGitHub API速率限制
使用具有适当权限的细粒度令牌。应用会在Supabase中缓存GitHub响应以避免重复API调用。
使用具有适当权限的细粒度令牌。应用会在Supabase中缓存GitHub响应以避免重复API调用。
Supabase认证在本地无法工作
在Supabase项目中配置GitHub OAuth提供商,并确保本地回调URL()已被列入允许列表。
在Supabase项目中配置GitHub OAuth提供商,并确保本地回调URL(
http://localhost:3001/auth/callback建筑不显示
检查Supabase行级安全策略是否允许匿名用户读取和表。
检查Supabase行级安全策略是否允许匿名用户读取
profilesbuildings窗户闪烁
这通常是Z轴战斗问题。沿法线轴为窗户网格位置添加微小偏移()。
这通常是Z轴战斗问题。沿法线轴为窗户网格位置添加微小偏移(
0.001大量建筑时出现性能问题
确保窗户使用实例化网格且LOD系统已激活。避免在渲染循环中创建新的实例——在组件外部定义或使用。
确保窗户使用实例化网格且LOD系统已激活。避免在渲染循环中创建新的
THREE.MaterialuseMemoStack Reference
技术栈参考
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| 3D Rendering | Three.js + @react-three/fiber + drei |
| Database | Supabase (PostgreSQL + RLS) |
| Auth | Supabase GitHub OAuth |
| Payments | Stripe |
| Styling | Tailwind CSS v4 + Silkscreen font |
| Hosting | Vercel |
| License | AGPL-3.0 |
| 层级 | 技术 |
|---|---|
| 框架 | Next.js 16(App Router, Turbopack) |
| 3D渲染 | Three.js + @react-three/fiber + drei |
| 数据库 | Supabase(PostgreSQL + RLS) |
| 认证 | Supabase GitHub OAuth |
| 支付 | Stripe |
| 样式 | Tailwind CSS v4 + Silkscreen字体 |
| 托管 | Vercel |
| 许可证 | AGPL-3.0 |