Possible to embed observable.js in Shiny for Python?

As shown on this page, a quarto project can embed observable.js code and render 3D scene created by three.js in a window.

Is it possible to embed observable.js or three.js code and render the 3D scene on a designated panel, or div, in a shiny for python app?

Allow me to reply myself. (I'm not sure if posing code generated by ai code assistant is allowed here. Nevertheless, here it is.)

The following demo app was generted by Claude.ai (free), after a few round of back-and-forth of asking it to fix the issues in the code based on error/warning messages, with a three.js 3D scene embeded that responds to the changes of the shiny ui components.

I think it's interesting in that I can use the demo code as a template to understand how to build the interactions between shiny for python and three.js.

What do you think?

# app.py
from shiny import App, ui, render, reactive
import asyncio

# Define the UI
app_ui = ui.page_fluid(
    ui.tags.head(
        # Include Three.js library from CDN
        ui.tags.script(
            src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
        ),
    ),
    ui.h1("Three.js in Shiny for Python"),
    ui.div(
        ui.div(
            id="threejs-container",
            style="width: 100%; height: 400px; border: 1px solid #ccc;",
        ),
        ui.input_slider(
            "rotation_speed", "Rotation Speed", 0.001, 0.1, 0.01, step=0.001
        ),
        # Using proper Shiny for Python color input
        ui.input_text("cube_color", "Cube Color (hex)", value="#6495ED"),
        ui.p("Example values: #FF0000 (red), #00FF00 (green), #0000FF (blue)"),
    ),
    # The JavaScript code that creates and manages the Three.js scene
    ui.tags.script("""
        // Initialize the scene once the DOM is ready
        document.addEventListener('DOMContentLoaded', function() {
            // Scene, camera, and renderer setup
            const container = document.getElementById('threejs-container');
            const scene = new THREE.Scene();
            scene.background = new THREE.Color(0xf0f0f0);
            
            const camera = new THREE.PerspectiveCamera(
                75, 
                container.clientWidth / container.clientHeight, 
                0.1, 
                1000
            );
            camera.position.z = 5;
            
            const renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(container.clientWidth, container.clientHeight);
            container.appendChild(renderer.domElement);
            
            // Add lighting
            const ambientLight = new THREE.AmbientLight(0x404040);
            scene.add(ambientLight);
            
            const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
            directionalLight.position.set(1, 1, 1);
            scene.add(directionalLight);
            
            // Create a cube
            const geometry = new THREE.BoxGeometry(2, 2, 2);
            const material = new THREE.MeshPhongMaterial({ 
                color: 0x6495ED,
                shininess: 100
            });
            const cube = new THREE.Mesh(geometry, material);
            scene.add(cube);
            
            // Handle window resize
            window.addEventListener('resize', function() {
                camera.aspect = container.clientWidth / container.clientHeight;
                camera.updateProjectionMatrix();
                renderer.setSize(container.clientWidth, container.clientHeight);
            });
            
            // Animation loop
            let rotationSpeed = 0.01;
            
            function animate() {
                requestAnimationFrame(animate);
                
                cube.rotation.x += rotationSpeed;
                cube.rotation.y += rotationSpeed;
                
                renderer.render(scene, camera);
            }
            
            // Start the animation
            animate();
            
            // Shiny input binding for rotation speed
            Shiny.addCustomMessageHandler('update_rotation_speed', function(speed) {
                rotationSpeed = parseFloat(speed);
            });
            
            // Shiny input binding for cube color
            Shiny.addCustomMessageHandler('update_cube_color', function(color) {
                try {
                    cube.material.color.set(color);
                } catch (e) {
                    console.error("Invalid color format:", color);
                }
            });
        });
    """),
)


# Define the server logic
def server(input, output, session):
    @render.text
    def current_speed():
        return f"Current rotation speed: {input.rotation_speed()}"

    # Using async function for the Effect
    @reactive.Effect
    async def _update_rotation_speed():
        speed = input.rotation_speed()
        await session.send_custom_message("update_rotation_speed", speed)

    @reactive.Effect
    async def _update_cube_color():
        color = input.cube_color()
        # Ensure it's a valid hex color
        if color.startswith("#") and (len(color) == 7 or len(color) == 4):
            await session.send_custom_message("update_cube_color", color)


# Create and run the app
app = App(app_ui, server)

# For running the app locally
if __name__ == "__main__":
    app.run()