From 4f274e52ce516aa8cbf3a0533dd043a4c5ed574a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fe=CC=81lix=20Pe=CC=81ault?= Date: Sat, 16 Jul 2022 18:55:47 +0200 Subject: [PATCH] =?UTF-8?q?[WIP]=20=E2=9C=A8=20Started=20new=20Globe=20fro?= =?UTF-8?q?m=20scratch=20using=20OGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- pnpm-lock.yaml | 16 +- .../organisms/InteractiveGlobe2.svelte | 173 ++++++++++ src/modules/globe2/frag.glsl | 37 ++ src/modules/globe2/index.ts | 324 ++++++++++++++++++ src/modules/globe2/pane.ts | 18 + src/modules/globe2/vertex.glsl | 32 ++ src/routes/__error.svelte | 4 +- src/routes/credits.svelte | 4 +- src/routes/index.svelte | 4 +- src/routes/locations.svelte | 4 +- src/routes/subscribe.svelte | 4 +- src/style/_typography.scss | 5 +- src/style/modules/_globe2.scss | 257 ++++++++++++++ 14 files changed, 872 insertions(+), 14 deletions(-) create mode 100644 src/components/organisms/InteractiveGlobe2.svelte create mode 100644 src/modules/globe2/frag.glsl create mode 100644 src/modules/globe2/index.ts create mode 100644 src/modules/globe2/pane.ts create mode 100644 src/modules/globe2/vertex.glsl create mode 100644 src/style/modules/_globe2.scss diff --git a/package.json b/package.json index d17834b..2fe30cd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "dayjs": "^1.11.3", "embla-carousel": "^6.2.0", "focus-visible": "^5.2.0", - "sanitize.css": "^13.0.0" + "ogl": "^0.0.97", + "sanitize.css": "^13.0.0", + "tweakpane": "^3.1.0" }, "devDependencies": { "@sveltejs/adapter-auto": "^1.0.0-next.61", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1c0246..19caf90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,9 @@ lockfileVersion: 5.4 specifiers: - '@sveltejs/adapter-auto': ^1.0.0-next.60 + '@sveltejs/adapter-auto': ^1.0.0-next.61 '@sveltejs/adapter-node': ^1.0.0-next.81 - '@sveltejs/adapter-vercel': ^1.0.0-next.62 + '@sveltejs/adapter-vercel': ^1.0.0-next.63 '@sveltejs/kit': ^1.0.0-next.377 '@types/animejs': ^3.1.5 '@typescript-eslint/eslint-plugin': ^5.30.6 @@ -16,6 +16,7 @@ specifiers: eslint: ^8.19.0 eslint-plugin-svelte3: ^4.0.0 focus-visible: ^5.2.0 + ogl: ^0.0.97 postcss: ^8.4.14 postcss-focus-visible: ^7.0.0 postcss-normalize: ^10.0.1 @@ -28,6 +29,7 @@ specifiers: svelte-preprocess: ^4.10.7 swell-node: ^4.0.10 tslib: ^2.4.0 + tweakpane: ^3.1.0 typescript: ^4.7.4 vite: ^3.0.0 @@ -36,7 +38,9 @@ dependencies: dayjs: 1.11.3 embla-carousel: 6.2.0 focus-visible: 5.2.0 + ogl: 0.0.97 sanitize.css: 13.0.0 + tweakpane: 3.1.0 devDependencies: '@sveltejs/adapter-auto': 1.0.0-next.61 @@ -1875,6 +1879,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /ogl/0.0.97: + resolution: {integrity: sha512-8VGNwb+BnVgg80uF2MDJGX+rLja8DPvmSsW1a3KCZO4pQF8sszRCgQVQmUA2EnoIYXtMUEztChkB0fuoFcWLxw==} + dev: false + /once/1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -3029,6 +3037,10 @@ packages: typescript: 4.7.4 dev: true + /tweakpane/3.1.0: + resolution: {integrity: sha512-PGAp/LPQdHwzL7/iAW4lV1p9iPQTti7YMjMWO48CoYjvZRS59RmgQnhEGzKzqST1JnmOYmQUjTe8bdhlZRJs5A==} + dev: false + /type-check/0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} diff --git a/src/components/organisms/InteractiveGlobe2.svelte b/src/components/organisms/InteractiveGlobe2.svelte new file mode 100644 index 0000000..9918f28 --- /dev/null +++ b/src/components/organisms/InteractiveGlobe2.svelte @@ -0,0 +1,173 @@ + + + + + + +
+
+
+
+ + + + {#if popinOpen} +
+ + +
+ {/if} +
\ No newline at end of file diff --git a/src/modules/globe2/frag.glsl b/src/modules/globe2/frag.glsl new file mode 100644 index 0000000..c07dcd4 --- /dev/null +++ b/src/modules/globe2/frag.glsl @@ -0,0 +1,37 @@ +precision highp float; +varying vec3 v_normal; +varying vec3 v_surfaceToLight; +varying vec3 v_surfaceToView; +varying vec2 v_uv; +uniform float u_dt; +uniform float u_shininess; +uniform sampler2D map; + +void main() { + // Re-normalize interpolated varyings + vec3 normal = normalize(v_normal); + vec3 surfaceToLightDirection = normalize(v_surfaceToLight); + vec3 surfaceToViewDirection = normalize(v_surfaceToView); + // Calculate Half-Vector, Vector that bisects the angle of reflection. + // This vector indecates the "brightest point" A "refrence vector" if you will. + vec3 halfVector = normalize(surfaceToLightDirection + surfaceToViewDirection); + // Then we can get the brightness at any point by seeing "how similar" the surface normal is to the refrence vector. + float light = dot(normal, surfaceToLightDirection); + + // By raising the specular vector to a power we can control the intensity of the light + float specular = 0.0; + + if (light > 0.0) { + specular = pow(dot(normal, halfVector), u_shininess * 100.0); + } + + // Mapping textures + vec4 map = texture2D(map, v_uv).rgba; + // vec3 spec = texture2D(specMap, v_uv).rgb; + + gl_FragColor.rgba = map; + // Add Point Lighting + gl_FragColor.rgba *= light; + // Add Specular Highlights + // gl_FragColor.rgb += specular * spec; +} \ No newline at end of file diff --git a/src/modules/globe2/index.ts b/src/modules/globe2/index.ts new file mode 100644 index 0000000..b2c03d0 --- /dev/null +++ b/src/modules/globe2/index.ts @@ -0,0 +1,324 @@ +// @ts-nocheck +import { Renderer, Camera, Vec3, Orbit, Sphere, Transform, Program, Mesh, Texture } from 'ogl' +// Shaders +import VERTEX_SHADER from '../../modules/globe2/vertex.glsl?raw' +import FRAGMENT_SHADER from '../../modules/globe2/frag.glsl?raw' + + +export class Globe { + constructor (options: Options) { + // Options + this.options = options + this.el = options.el + this.parent = options.parent + this.markers = options.markers || [] + + // Parameters + this.params = { + autoRotate: options.autoRotate, + speed: options.speed, + } + + // Misc + this.hoveringMarker = false + this.dragging = false + this.webgl = WebGLSupport() !== null + this.pane = undefined + + // Run globe after check for WebGL support + if (this.webgl) { + this.build() + this.resize({ + width: this.options.width, + height: this.options.height, + }) + this.render() + } + + // Add GUI panel if activated + if (this.options.pane) { + import('./pane').then(({ createPane }) => { + createPane(this) + }) + } + } + + + /** + * Build scene + */ + build () { + // Create renderer + this.renderer = new Renderer({ + dpr: this.options.dpr || 1, + alpha: true, + premultiplyAlpha: false, + antialias: this.options.antialias || true, + }) + this.gl = this.renderer.gl + + // Create camera + this.camera = new Camera(this.gl) + this.camera.position.set(0, 0, 1.315) + + // Create controls + this.controls = new Orbit(this.camera, { + element: this.el, + target: new Vec3(0,0,0), + enableZoom: false, + enablePan: false, + autoRotate: false, + ease: 0.2, + minPolarAngle: Math.PI / 4, + maxPolarAngle: Math.PI / 1.5, + }) + + // Append canvas to scene + this.el.appendChild(this.gl.canvas) + + // Create scene and geometry + this.scene = new Transform() + this.geometry = new Sphere(this.gl, { + widthSegments: 64, + heightSegments: 64, + }) + + // Create light + // this.light = new Vec3(0, 50, 150) + this.light = new Vec3(0, 0, 1000) + + // Add map texture + const map = new Texture(this.gl) + const img = new Image() + img.onload = () => (map.image = img) + img.src = this.options.mapFile + + // Create program + this.program = new Program(this.gl, { + vertex: VERTEX_SHADER, + fragment: FRAGMENT_SHADER, + uniforms: { + u_dt: { value: 0 }, + u_lightWorldPosition: { value: this.light }, // Position of the Light + u_shininess: { value: 1.0 }, + map: { value: map }, // Color Map + }, + transparent: true, + }) + + // Create mesh + this.mesh = new Mesh(this.gl, { + geometry: this.geometry, + program: this.program, + }) + this.mesh.setParent(this.scene) + + // Define random continent position + if (this.options.rotationStart) { + this.mesh.rotation.y = degToRad(this.options.rotationStart * -1) || 0 + } + + // Add events + this.addEvents() + + // Setup markers + if (this.markers) { + this.setupMarkers() + } + } + + + /** + * Add events + */ + addEvents () { + // When clicking on globe + this.gl.canvas.addEventListener('mousedown', () => { + this.dragging = true + this.gl.canvas.classList.add('is-grabbing') + }, false) + + // When releasing globe click + this.gl.canvas.addEventListener('mouseup', () => { + this.dragging = false + this.gl.canvas.classList.remove('is-grabbing') + }, false) + } + + + /** + * Markers + */ + // Get marker from DOM element + getMarker (id: string) { + const marker = this.parent.querySelector(`[data-location="${id}"]`) + if (marker) { + return marker + } + } + + // Setup markers + setupMarkers () { + this.markers.forEach((marker: Marker) => { + const markerEl = this.getMarker(marker.slug) + + // Entering marker + markerEl.addEventListener('mouseenter', () => { + this.hoveringMarker = true + }, false) + // Leaving marker + markerEl.addEventListener('mouseleave', () => { + this.hoveringMarker = false + }, false) + + // Define position + const position = lonlatVec3(marker.lng, marker.lat) + + // Scale marker position to fit globe size + marker.position = [position[0] *= 0.5, position[1] *= 0.5, position[2] *= 0.5] + + console.log(marker) + return marker + }) + } + + // Update markers + updateMarkers () { + this.markers.forEach((marker: Marker) => { + const markerEl = this.getMarker(marker.slug) + const screenVector = new Vec3(0,0,0) + screenVector.copy(marker.position) + this.camera.project(screenVector) + + // let posX = (screenVector.x + 1) * (this.options.width / 1.315) + // // posX /= this.mesh.rotation.y + // let posY = (1 - screenVector.y) * (this.options.height / 1.315) + // markerEl.style.transform = `translate3d(${posX}px, ${posY}px, 0)` + }) + } + + + /** + * Resize method + */ + resize (options: any) { + // this.renderer.setSize(window.innerWidth, window.innerHeight) + this.renderer.setSize(options.width || this.options.width, options.height || this.options.height) + this.camera.perspective({ + aspect: this.gl.canvas.width / this.gl.canvas.height + }) + } + + + /** + * Update method + */ + render () { + // Stop render if not dragging but hovering marker + if (!this.dragging && this.hoveringMarker) return + + // Update globe rotation + if (this.params.autoRotate) { + this.mesh.rotation.y += this.params.speed + } + + // Update controls and renderer + this.controls.update(this.params) + this.renderer.render({ + scene: this.scene, + camera: this.camera, + }) + + // Update light + // this.light.set(this.camera.position) + // this.program.uniforms.u_lightWorldPosition.value = [this.mesh.rotation.y * 1, 50, 150] + + // Update markers + this.updateMarkers() + } + + + /** + * Destroy + */ + destroy () { + console.log('destroy globe2') + + this.gl = null + this.scene = null + this.camera = null + this.mesh = null + this.renderer = null + this.controls.remove() + + if (this.pane) { + this.pane.dispose() + } + } +} + + +/** + * Types + */ +type Options = { + el: HTMLElement + parent: HTMLElement + width: number + height: number + mapFile: string + dpr: number + autoRotate: boolean + speed: number + rotationStart?: number + markers?: any[] + pane?: boolean +} +export type Marker = { + name: string + slug: string + country: { + name: string + slug: string + flag: { + id: string + } + } + lat: number + lng: number + position?: number[] +} + + +/* ========================================================================== + HELPERS +========================================================================== */ +/** + * Detect WebGL support + */ +function WebGLSupport () { + try { + var canvas = document.createElement('canvas') + return !!window.WebGLRenderingContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) + } catch(e) { + return false + } +} + +/** + * Convert lat/lng to Vec3 + */ +function lonlatVec3 (longitude: number, latitude: number) { + const lat = latitude * Math.PI / 180 + const lng = -longitude * Math.PI / 180 + return new Vec3( + Math.cos(lat) * Math.cos(lng), + Math.sin(lat), + Math.cos(lat) * Math.sin(lng) + ) +} + +/** + * Convert Degrees to Radians + */ +const degToRad = (deg: number) => deg * Math.PI / 180 \ No newline at end of file diff --git a/src/modules/globe2/pane.ts b/src/modules/globe2/pane.ts new file mode 100644 index 0000000..48e98c3 --- /dev/null +++ b/src/modules/globe2/pane.ts @@ -0,0 +1,18 @@ +import { Pane } from 'tweakpane' + +export const createPane = (ctx: any) => { + ctx.pane = new Pane({ + container: ctx.parent, + title: 'Settings', + }) + + ctx.pane.addInput(ctx.params, 'autoRotate', { + label: 'Auto-rotate', + }) + ctx.pane.addInput(ctx.params, 'speed', { + label: 'Rotation speed', + min: 0.0005, + max: 0.025, + step: 0.00025, + }) +} \ No newline at end of file diff --git a/src/modules/globe2/vertex.glsl b/src/modules/globe2/vertex.glsl new file mode 100644 index 0000000..1d2c12d --- /dev/null +++ b/src/modules/globe2/vertex.glsl @@ -0,0 +1,32 @@ +attribute vec2 uv; +attribute vec3 position; +attribute vec3 normal; +uniform mat4 modelMatrix; +uniform mat4 modelViewMatrix; +uniform mat4 projectionMatrix; +uniform mat3 normalMatrix; +uniform vec3 u_lightWorldPosition; +uniform vec3 cameraPosition; + +varying vec3 v_normal; +varying vec3 v_surfaceToLight; +varying vec3 v_surfaceToView; +varying vec2 v_uv; + +void main () { + // Pass UV information to Fragment Shader + v_uv = uv; + + // Calculate World Space Normal + v_normal = normalMatrix * normal; + + // Compute the world position of the surface + vec3 surfaceWorldPosition = mat3(modelMatrix) * position; + + // Vector from the surface, to the light + v_surfaceToLight = u_lightWorldPosition - surfaceWorldPosition; + + // Vector from the surface, to the camera + v_surfaceToView = cameraPosition - surfaceWorldPosition; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} \ No newline at end of file diff --git a/src/routes/__error.svelte b/src/routes/__error.svelte index 478fb5e..b18e230 100644 --- a/src/routes/__error.svelte +++ b/src/routes/__error.svelte @@ -10,7 +10,7 @@ import PageTransition from '$components/PageTransition.svelte' import BoxCTA from '$components/atoms/BoxCTA.svelte' import Heading from '$components/molecules/Heading.svelte' - import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte' + import InteractiveGlobe2 from '$components/organisms/InteractiveGlobe2.svelte' import ListCTAs from '$components/organisms/ListCTAs.svelte' import Locations from '$components/organisms/Locations.svelte' import ShopModule from '$components/organisms/ShopModule.svelte' @@ -70,7 +70,7 @@
- +
diff --git a/src/routes/credits.svelte b/src/routes/credits.svelte index a37dd59..d918b29 100644 --- a/src/routes/credits.svelte +++ b/src/routes/credits.svelte @@ -9,7 +9,7 @@ import Metas from '$components/Metas.svelte' import PageTransition from '$components/PageTransition.svelte' import SiteTitle from '$components/atoms/SiteTitle.svelte' - import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte' + import InteractiveGlobe2 from '$components/organisms/InteractiveGlobe2.svelte' import Image from '$components/atoms/Image.svelte' export let data: any @@ -139,5 +139,5 @@
- + \ No newline at end of file diff --git a/src/routes/index.svelte b/src/routes/index.svelte index 509f7e1..2e53532 100644 --- a/src/routes/index.svelte +++ b/src/routes/index.svelte @@ -18,7 +18,7 @@ import ScrollingTitle from '$components/atoms/ScrollingTitle.svelte' import BoxCTA from '$components/atoms/BoxCTA.svelte' import DiscoverText from '$components/atoms/DiscoverText.svelte' - import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte' + import InteractiveGlobe2 from '$components/organisms/InteractiveGlobe2.svelte' import Collage from '$components/organisms/Collage.svelte' import Locations from '$components/organisms/Locations.svelte' import ListCTAs from '$components/organisms/ListCTAs.svelte' @@ -138,7 +138,7 @@
- + diff --git a/src/routes/locations.svelte b/src/routes/locations.svelte index 016756b..da60d8f 100644 --- a/src/routes/locations.svelte +++ b/src/routes/locations.svelte @@ -7,7 +7,7 @@ // Components import Metas from '$components/Metas.svelte' import PageTransition from '$components/PageTransition.svelte' - import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte' + import InteractiveGlobe2 from '$components/organisms/InteractiveGlobe2.svelte' import Locations from '$components/organisms/Locations.svelte' import ShopModule from '$components/organisms/ShopModule.svelte' import NewsletterModule from '$components/organisms/NewsletterModule.svelte' @@ -28,7 +28,7 @@ />
- +
diff --git a/src/routes/subscribe.svelte b/src/routes/subscribe.svelte index 9fe0ca3..fac07bc 100644 --- a/src/routes/subscribe.svelte +++ b/src/routes/subscribe.svelte @@ -12,7 +12,7 @@ import Image from '$components/atoms/Image.svelte' import Heading from '$components/molecules/Heading.svelte' import EmailForm from '$components/molecules/EmailForm.svelte' - import InteractiveGlobe from '$components/organisms/InteractiveGlobe.svelte' + import InteractiveGlobe2 from '$components/organisms/InteractiveGlobe2.svelte' export let data: any export let issues: any[] @@ -96,5 +96,5 @@
- + \ No newline at end of file diff --git a/src/style/_typography.scss b/src/style/_typography.scss index 72c3312..9853a6a 100644 --- a/src/style/_typography.scss +++ b/src/style/_typography.scss @@ -27,7 +27,6 @@ @include bp (sm) { font-size: clamp(#{rem(40px)}, 7vw, #{rem(88px)}); } - } // House Number @@ -157,4 +156,8 @@ font-weight: 500; text-transform: uppercase; letter-spacing: 1px; + + &--small { + font-size: rem(10px); + } } \ No newline at end of file diff --git a/src/style/modules/_globe2.scss b/src/style/modules/_globe2.scss new file mode 100644 index 0000000..657ba06 --- /dev/null +++ b/src/style/modules/_globe2.scss @@ -0,0 +1,257 @@ +// Globe +.globe { + position: relative; + z-index: 10; + user-select: none; + + + // Inner + &__inner { + position: relative; + width: clamp(700px, 100vw, 1315px); + margin-left: auto; + margin-right: auto; + + &:after { + content: ""; + display: block; + padding-bottom: 100%; + } + } + // Canvas + &__canvas { + position: absolute; + z-index: 2; + top: 0; + left: 50%; + transform: translate3d(-50%, 0, 0); + width: 100%; + height: 100%; + } + :global(canvas) { + position: absolute; + z-index: 10; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: grab; + } + + // Markers + &__markers { + position: absolute; + z-index: 2; + top: 0; + left: 0; + pointer-events: none; + user-select: none; + + li { + display: block; + margin: 0; + padding: 0; + } + } + + // Marker + &__marker { + position: absolute; + top: 0; + left: 0; + user-select: none; + transform: translate3d(var(--x), var(--y), 0); + + a { + position: relative; + text-decoration: none; + color: $color-secondary; + pointer-events: auto; + + dl > * { + transition: opacity 0.5s; + } + dt { + line-height: 1; + } + dd { + color: $color-gray; + margin-top: 4px; + line-height: 1; + opacity: 0; + } + + // Dot + &:before { + content: ""; + display: block; + position: absolute; + top: 10px; + left: -16px; + width: 8px; + height: 8px; + border-radius: 100%; + background: $color-secondary; + } + } + + /* + ** States + */ + // Has name + &.is-dot-only { + dt, dd { + opacity: 0; + } + } + + // Has country + &.has-country { + dd { + opacity: 1; + } + } + } + + // Cluster + &__cluster { + position: absolute; + z-index: 10; + top: 300px; + left: 300px; + pointer-events: auto; + + button { + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 100%; + background: rgba($color-secondary, 0.2); + transition: box-shadow 0.5s var(--ease-quart), background 0.5s var(--ease-quart); + } + + &:hover { + button { + background: rgba($color-secondary, 0.3); + box-shadow: 0 0 0 8px rgba($color-secondary, 0.1); + } + } + } + + // Popin + &__popin { + position: absolute; + z-index: 10; + top: 12vw; + left: 50%; + transform: translate3d(-50%, 0, 0); + pointer-events: auto; + width: 546px; + padding: 24px 32px; + border-radius: 16px; + background: #fff; + --shadow-color: #{rgba(45, 4, 88, 0.05)}; + box-shadow: + 0 6px 6px var(--shadow-color), + 0 12px 12px var(--shadow-color), + 0 24px 24px var(--shadow-color), + 0 40px 40px var(--shadow-color); + + ul { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px 16px; + } + li { + display: block; + transform: translateZ(0); + } + a { + display: flex; + align-items: center; + padding: 12px; + border-radius: 6px; + text-decoration: none; + transition: background 0.3s var(--ease-quart); + + &:hover { + background: rgba($color-secondary, 0.1); + } + } + + // Flag + :global(.flag) { + display: block; + width: 28px; + height: 28px; + overflow: hidden; + border-radius: 100%; + transform: translateZ(0); + + :global(img) { + display: block; + width: 100%; + height: 100%; + } + } + + // Details + dl { + margin-left: 16px; + } + dt { + margin-bottom: 4px; + line-height: 1.2; + } + dd { + color: $color-gray; + line-height: 1; + } + + // Close buttom + .close { + position: absolute; + z-index: 2; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: $color-primary-darker; + background: rgba($color-secondary, 0.15); + border-radius: 100%; + transition: background 0.3s var(--ease-quart); + + svg { + display: block; + width: 9px; + height: 9px; + } + + &:hover { + background: rgba($color-secondary, 0.3); + } + } + } + + + /* + ** States and Variants + */ + // When dragging + :global(.is-grabbing) { + cursor: grabbing; + } + + + // Tweakpane + :global(.tp-rotv) { + position: absolute; + top: 0; + right: 0; + width: 300px; + } +} \ No newline at end of file