Skip to content

Commit 6d810a4

Browse files
ProspectorChecksumDevfangmarks
authored
Servers marketing enhancements (#3252)
* feat: locations page + stock callouts * feat: misalligned but spirits there!! * fix readability on colors on globe * Enhancements to globe * Fix out of stock indicator styling * Start globe near US and slow speed * Remove debug statement * Switch from capacity to stock API * Make custom use its own stock checker * Fix lint, add changelog entries --------- Co-authored-by: Elizabeth <checksum@pyro.host> Co-authored-by: Lio <git@lio.cat>
1 parent 098519d commit 6d810a4

File tree

8 files changed

+678
-145
lines changed

8 files changed

+678
-145
lines changed

apps/frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
"pinia": "^2.1.7",
5858
"qrcode.vue": "^3.4.0",
5959
"semver": "^7.5.4",
60+
"three": "^0.172.0",
61+
"@types/three": "^0.172.0",
6062
"vue-multiselect": "3.0.0-alpha.2",
6163
"vue-typed-virtual-list": "^1.0.10",
6264
"vue3-ace-editor": "^2.2.4",
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
<template>
2+
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
3+
<div
4+
v-for="location in locations"
5+
:key="location.name"
6+
:class="{
7+
'opacity-0': !showLabels,
8+
hidden: !isLocationVisible(location),
9+
'z-40': location.clicked,
10+
}"
11+
:style="{
12+
position: 'absolute',
13+
left: `${location.screenPosition?.x || 0}px`,
14+
top: `${location.screenPosition?.y || 0}px`,
15+
}"
16+
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
17+
@click="toggleLocationClicked(location)"
18+
>
19+
<div
20+
:class="{
21+
'animate-pulse': location.active,
22+
'border-gray-400': !location.active,
23+
'border-purple bg-purple': location.active,
24+
'border-dashed': !location.active,
25+
'opacity-40': !location.active,
26+
}"
27+
class="my-3 size-2.5 shrink-0 rounded-full border-2"
28+
></div>
29+
<div
30+
class="expanding-item"
31+
:class="{
32+
expanded: location.clicked,
33+
}"
34+
>
35+
<div class="whitespace-nowrap text-sm">
36+
<span class="ml-2"> {{ location.name }} </span>
37+
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
38+
</div>
39+
</div>
40+
</div>
41+
</div>
42+
</template>
43+
44+
<script setup>
45+
import * as THREE from "three";
46+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
47+
import { ref, onMounted, onUnmounted } from "vue";
48+
49+
const container = ref(null);
50+
const showLabels = ref(false);
51+
52+
const locations = ref([
53+
// Active locations
54+
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
55+
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
56+
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
57+
{ name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false },
58+
// Future Locations
59+
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
60+
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
61+
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
62+
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
63+
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
64+
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
65+
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
66+
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
67+
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
68+
]);
69+
70+
const isLocationVisible = (location) => {
71+
if (!location.screenPosition || !globe) return false;
72+
73+
const vector = latLngToVector3(location.lat, location.lng).clone();
74+
vector.applyMatrix4(globe.matrixWorld);
75+
76+
const cameraVector = new THREE.Vector3();
77+
camera.getWorldPosition(cameraVector);
78+
79+
const viewVector = vector.clone().sub(cameraVector).normalize();
80+
81+
const normal = vector.clone().normalize();
82+
83+
const dotProduct = normal.dot(viewVector);
84+
85+
return dotProduct < -0.15;
86+
};
87+
88+
const toggleLocationClicked = (location) => {
89+
console.log("clicked", location.name);
90+
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
91+
};
92+
93+
let scene, camera, renderer, globe, controls;
94+
let animationFrame;
95+
96+
const init = () => {
97+
scene = new THREE.Scene();
98+
camera = new THREE.PerspectiveCamera(
99+
45,
100+
container.value.clientWidth / container.value.clientHeight,
101+
0.1,
102+
1000,
103+
);
104+
renderer = new THREE.WebGLRenderer({
105+
antialias: true,
106+
alpha: true,
107+
powerPreference: "low-power",
108+
});
109+
renderer.setPixelRatio(window.devicePixelRatio);
110+
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
111+
container.value.appendChild(renderer.domElement);
112+
113+
const geometry = new THREE.SphereGeometry(5, 64, 64);
114+
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
115+
outlineTexture.minFilter = THREE.LinearFilter;
116+
outlineTexture.magFilter = THREE.LinearFilter;
117+
118+
const material = new THREE.ShaderMaterial({
119+
uniforms: {
120+
outlineTexture: { value: outlineTexture },
121+
globeColor: { value: new THREE.Color("#60fbb5") },
122+
},
123+
vertexShader: `
124+
varying vec2 vUv;
125+
void main() {
126+
vUv = uv;
127+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
128+
}
129+
`,
130+
fragmentShader: `
131+
uniform sampler2D outlineTexture;
132+
uniform vec3 globeColor;
133+
varying vec2 vUv;
134+
void main() {
135+
vec4 texColor = texture2D(outlineTexture, vUv);
136+
137+
float brightness = max(max(texColor.r, texColor.g), texColor.b);
138+
gl_FragColor = vec4(globeColor, brightness * 0.8);
139+
}
140+
`,
141+
transparent: true,
142+
side: THREE.FrontSide,
143+
});
144+
145+
globe = new THREE.Mesh(geometry, material);
146+
scene.add(globe);
147+
148+
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
149+
const atmosphereMaterial = new THREE.ShaderMaterial({
150+
transparent: true,
151+
side: THREE.BackSide,
152+
uniforms: {
153+
color: { value: new THREE.Color("#56f690") },
154+
viewVector: { value: camera.position },
155+
},
156+
vertexShader: `
157+
uniform vec3 viewVector;
158+
varying float intensity;
159+
void main() {
160+
vec3 vNormal = normalize(normalMatrix * normal);
161+
vec3 vNormel = normalize(normalMatrix * viewVector);
162+
intensity = pow(0.7 - dot(vNormal, vNormel), 2.0);
163+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
164+
}
165+
`,
166+
fragmentShader: `
167+
uniform vec3 color;
168+
varying float intensity;
169+
void main() {
170+
gl_FragColor = vec4(color, intensity * 0.4);
171+
}
172+
`,
173+
});
174+
175+
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
176+
scene.add(atmosphere);
177+
178+
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
179+
scene.add(ambientLight);
180+
181+
camera.position.z = 15;
182+
183+
controls = new OrbitControls(camera, renderer.domElement);
184+
controls.enableDamping = true;
185+
controls.dampingFactor = 0.05;
186+
controls.rotateSpeed = 0.3;
187+
controls.enableZoom = false;
188+
controls.enablePan = false;
189+
controls.autoRotate = true;
190+
controls.autoRotateSpeed = 0.05;
191+
controls.minPolarAngle = Math.PI * 0.3;
192+
controls.maxPolarAngle = Math.PI * 0.7;
193+
194+
globe.rotation.y = Math.PI * 1.9;
195+
globe.rotation.x = Math.PI * 0.15;
196+
};
197+
198+
const animate = () => {
199+
animationFrame = requestAnimationFrame(animate);
200+
controls.update();
201+
202+
locations.value.forEach((location) => {
203+
const position = latLngToVector3(location.lat, location.lng);
204+
const vector = position.clone();
205+
vector.applyMatrix4(globe.matrixWorld);
206+
207+
const coords = vector.project(camera);
208+
const screenPosition = {
209+
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
210+
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
211+
};
212+
location.screenPosition = screenPosition;
213+
});
214+
215+
renderer.render(scene, camera);
216+
};
217+
218+
const latLngToVector3 = (lat, lng) => {
219+
const phi = (90 - lat) * (Math.PI / 180);
220+
const theta = (lng + 180) * (Math.PI / 180);
221+
const radius = 5;
222+
223+
return new THREE.Vector3(
224+
-radius * Math.sin(phi) * Math.cos(theta),
225+
radius * Math.cos(phi),
226+
radius * Math.sin(phi) * Math.sin(theta),
227+
);
228+
};
229+
230+
const handleResize = () => {
231+
if (!container.value) return;
232+
camera.aspect = container.value.clientWidth / container.value.clientHeight;
233+
camera.updateProjectionMatrix();
234+
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
235+
};
236+
237+
onMounted(() => {
238+
init();
239+
animate();
240+
window.addEventListener("resize", handleResize);
241+
242+
setTimeout(() => {
243+
showLabels.value = true;
244+
}, 1000);
245+
});
246+
247+
onUnmounted(() => {
248+
if (animationFrame) {
249+
cancelAnimationFrame(animationFrame);
250+
}
251+
window.removeEventListener("resize", handleResize);
252+
if (renderer) {
253+
renderer.dispose();
254+
}
255+
if (container.value) {
256+
container.value.innerHTML = "";
257+
}
258+
});
259+
</script>
260+
261+
<style scoped>
262+
@keyframes pulse {
263+
0% {
264+
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
265+
}
266+
70% {
267+
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
268+
}
269+
100% {
270+
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
271+
}
272+
}
273+
274+
.animate-pulse {
275+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
276+
}
277+
278+
.center-on-top-left {
279+
transform: translate(-50%, -50%);
280+
}
281+
282+
.expanding-item.expanded {
283+
grid-template-columns: 1fr;
284+
}
285+
286+
@media (hover: hover) {
287+
.location-button:hover .expanding-item {
288+
grid-template-columns: 1fr;
289+
}
290+
}
291+
292+
.expanding-item {
293+
display: grid;
294+
grid-template-columns: 0fr;
295+
transition: grid-template-columns 0.15s ease-in-out;
296+
overflow: hidden;
297+
298+
> div {
299+
overflow: hidden;
300+
}
301+
}
302+
303+
@media (prefers-reduced-motion) {
304+
.expanding-item {
305+
transition: none !important;
306+
}
307+
}
308+
</style>

0 commit comments

Comments
 (0)