mirror of
https://github.com/borbann-platform/backend-api.git
synced 2025-12-18 20:24:05 +01:00
refactor: restructure the frontend
This commit is contained in:
parent
97734887b7
commit
7ab14fad02
@ -1,36 +1,88 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
```markdown
|
||||||
|
frontend/
|
||||||
## Getting Started
|
├── features/ # <= NEW: Feature-specific modules
|
||||||
|
│ ├── map/
|
||||||
First, run the development server:
|
│ │ ├── api/ # API calls specific to the map
|
||||||
|
│ │ │ └── mapApi.ts # (Example)
|
||||||
```bash
|
│ │ ├── components/ # Map-specific UI components
|
||||||
npm run dev
|
│ │ │ ├── analytics-overlay.tsx
|
||||||
# or
|
│ │ │ ├── analytics-panel.tsx
|
||||||
yarn dev
|
│ │ │ ├── area-chart.tsx
|
||||||
# or
|
│ │ │ ├── chat-bot.tsx
|
||||||
pnpm dev
|
│ │ │ ├── chat-overlay.tsx
|
||||||
# or
|
│ │ │ ├── filters-overlay.tsx
|
||||||
bun dev
|
│ │ │ ├── map-container.tsx
|
||||||
|
│ │ │ ├── map-header.tsx
|
||||||
|
│ │ │ ├── map-sidebar.tsx
|
||||||
|
│ │ │ ├── property-filters.tsx
|
||||||
|
│ │ │ └── overlay-system/ # Overlay specific components
|
||||||
|
│ │ │ ├── overlay-context.tsx
|
||||||
|
│ │ │ ├── overlay-dock.tsx
|
||||||
|
│ │ │ └── overlay.tsx
|
||||||
|
│ │ ├── hooks/ # Map-specific hooks
|
||||||
|
│ │ │ └── useMapInteractions.ts # (Example)
|
||||||
|
│ │ ├── types/ # Map-related types/interfaces
|
||||||
|
│ │ │ └── index.ts # (Example)
|
||||||
|
│ │ └── utils/ # Map-specific utilities
|
||||||
|
│ │ └── mapHelpers.ts # (Example)
|
||||||
|
│ └── model-explanation/
|
||||||
|
│ ├── components/ # Model Explanation specific components
|
||||||
|
│ │ ├── feature-importance-chart.tsx
|
||||||
|
│ │ └── price-comparison-chart.tsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── explanationApi.ts # (Example)
|
||||||
|
│ └── types/
|
||||||
|
│ └── index.ts # (Example)
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Shadcn UI components (KEEP AS IS)
|
||||||
|
│ │ └── ... (accordion.tsx, button.tsx, etc.)
|
||||||
|
│ └── common/ # <= NEW: Shared, app-wide components
|
||||||
|
│ ├── ThemeProvider.tsx # Moved from root components
|
||||||
|
│ ├── ThemeToggle.tsx # Moved from root components
|
||||||
|
│ ├── ThemeController.tsx # Moved from root components
|
||||||
|
│ └── PageLayout.tsx # (Example new shared layout)
|
||||||
|
│
|
||||||
|
├── lib/ # Core utilities, constants, config
|
||||||
|
│ └── utils.ts # (Keep cn function here)
|
||||||
|
│
|
||||||
|
├── hooks/ # Shared, app-wide hooks
|
||||||
|
│ ├── use-toast.ts # (Keep useToast here)
|
||||||
|
│ └── use-mobile.tsx # (Keep useIsMobile here)
|
||||||
|
│
|
||||||
|
├── services/ # <= OPTIONAL: Centralized API layer (alternative to features/\*/api)
|
||||||
|
│ └── apiClient.ts # (Example API client setup)
|
||||||
|
│
|
||||||
|
├── store/ # <= OPTIONAL: Global state management (e.g., Zustand, Jotai)
|
||||||
|
│ └── mapStore.ts # (Example store)
|
||||||
|
│
|
||||||
|
├── types/ # <= NEW: Shared, app-wide types/interfaces
|
||||||
|
│ ├── index.ts # Barrel file for types
|
||||||
|
│ └── api.ts # Example: General API response types
|
||||||
|
│
|
||||||
|
├── app/ # Next.js app router (routing, layouts, pages)
|
||||||
|
│ ├── (routes)/ # Keep actual route structure here
|
||||||
|
│ │ ├── map/
|
||||||
|
│ │ │ ├── layout.tsx # Imports common layout, feature components
|
||||||
|
│ │ │ └── page.tsx # Imports components from 'features/map/components'
|
||||||
|
│ │ ├── model-explanation/
|
||||||
|
│ │ │ └── page.tsx # Imports components from 'features/model-explanation/components'
|
||||||
|
│ │ └── page.tsx # Root page
|
||||||
|
│ ├── layout.tsx # Root layout (includes ThemeProvider)
|
||||||
|
│ ├── globals.css
|
||||||
|
│ └── favicon.ico
|
||||||
|
│
|
||||||
|
├── public/ # Static assets
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── .gitignore
|
||||||
|
├── components.json
|
||||||
|
├── eslint.config.mjs
|
||||||
|
├── next-env.d.ts
|
||||||
|
├── next.config.ts
|
||||||
|
├── package.json
|
||||||
|
├── pnpm-lock.yaml
|
||||||
|
├── postcss.config.mjs
|
||||||
|
├── tailwind.config.ts
|
||||||
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
703
frontend/app/(routes)/map-explanation/page.tsx
Normal file
703
frontend/app/(routes)/map-explanation/page.tsx
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/app/(routes)/model-explanation/page.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Info,
|
||||||
|
ArrowRight,
|
||||||
|
Home,
|
||||||
|
Building,
|
||||||
|
Ruler,
|
||||||
|
Calendar,
|
||||||
|
Coins,
|
||||||
|
Droplets,
|
||||||
|
Wind,
|
||||||
|
Sun,
|
||||||
|
Car,
|
||||||
|
School,
|
||||||
|
ShoppingBag,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Common UI components
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
// Removed SidebarProvider & ThemeProvider - should be in root layout
|
||||||
|
// Removed MapSidebar - assuming it's not needed here or use a common one
|
||||||
|
|
||||||
|
// Feature-specific components
|
||||||
|
import { FeatureImportanceChart } from "@/features/model-explanation/components/feature-importance-chart";
|
||||||
|
import { PriceComparisonChart } from "@/features/model-explanation/components/price-comparison-chart";
|
||||||
|
|
||||||
|
// Feature-specific API and types
|
||||||
|
import { fetchModelExplanation } from "@/features/model-explanation/api/explanationApi";
|
||||||
|
import type { ModelExplanationData, PropertyBaseDetails } from "@/features/model-explanation/types";
|
||||||
|
|
||||||
|
export default function ModelExplanationPage() {
|
||||||
|
const [activeStep, setActiveStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [explanationData, setExplanationData] = useState<ModelExplanationData | null>(null);
|
||||||
|
|
||||||
|
// State for interactive elements based on fetched data
|
||||||
|
const [propertySize, setPropertySize] = useState<number>(0);
|
||||||
|
const [propertyAge, setPropertyAge] = useState<number>(0);
|
||||||
|
|
||||||
|
// Fetch data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadExplanation() {
|
||||||
|
setIsLoading(true);
|
||||||
|
// TODO: Get actual property ID from route params or state
|
||||||
|
const propertyId = "dummy-prop-123";
|
||||||
|
const response = await fetchModelExplanation(propertyId);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setExplanationData(response.data);
|
||||||
|
// Initialize sliders with fetched data
|
||||||
|
setPropertySize(response.data.propertyDetails.size);
|
||||||
|
setPropertyAge(response.data.propertyDetails.age);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load model explanation:", response.error);
|
||||||
|
// Handle error state in UI
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
loadExplanation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stepper configuration
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, title: "Property Details", icon: Home },
|
||||||
|
{ id: 2, title: "Feature Analysis", icon: Ruler },
|
||||||
|
{ id: 3, title: "Market Comparison", icon: Building },
|
||||||
|
{ id: 4, title: "Environmental Factors", icon: Droplets },
|
||||||
|
{ id: 5, title: "Final Prediction", icon: Coins },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate adjusted price based on slider interaction
|
||||||
|
const calculateAdjustedPrice = () => {
|
||||||
|
if (!explanationData) return 0;
|
||||||
|
// Simple formula for demonstration - refine with actual logic if possible
|
||||||
|
const basePrice = explanationData.propertyDetails.predictedPrice;
|
||||||
|
const baseSize = explanationData.propertyDetails.size;
|
||||||
|
const baseAge = explanationData.propertyDetails.age;
|
||||||
|
|
||||||
|
const sizeImpact = (propertySize - baseSize) * 50000; // 50,000 THB per sqm diff
|
||||||
|
const ageImpact = (baseAge - propertyAge) * 200000; // 200,000 THB per year newer
|
||||||
|
|
||||||
|
return basePrice + sizeImpact + ageImpact;
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustedPrice = explanationData ? calculateAdjustedPrice() : 0;
|
||||||
|
|
||||||
|
// Loading State UI
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden p-6">
|
||||||
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
|
<Skeleton className="h-10 w-1/3 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-2/3 mb-8" />
|
||||||
|
<Skeleton className="h-10 w-full mb-4" />
|
||||||
|
<Skeleton className="h-2 w-full mb-8" />
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error State UI
|
||||||
|
if (!explanationData) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center p-6">
|
||||||
|
<p className="text-destructive">Failed to load model explanation.</p>
|
||||||
|
<Link href="/map">
|
||||||
|
<Button variant="link" className="mt-4">
|
||||||
|
Back to Map
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Content UI
|
||||||
|
const { propertyDetails, features, similarProperties, environmentalFactors, confidence, priceRange } =
|
||||||
|
explanationData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Assuming ThemeProvider is in the root layout
|
||||||
|
// Assuming SidebarProvider and a common sidebar are in root layout or parent layout
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{" "}
|
||||||
|
{/* Adjusted for page content */}
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex h-14 items-center justify-between border-b px-4 bg-background flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link href="/map" className="hover:text-foreground">
|
||||||
|
Map
|
||||||
|
</Link>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
<span className="font-medium text-foreground">Price Prediction Model</span>
|
||||||
|
</div>
|
||||||
|
{/* Add any specific header actions if needed */}
|
||||||
|
</header>
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{" "}
|
||||||
|
{/* Make content area scrollable */}
|
||||||
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Explainable Price Prediction Model</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Understand how our AI model predicts property prices and what factors influence the valuation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps navigation */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<Button
|
||||||
|
key={step.id}
|
||||||
|
variant={activeStep === step.id ? "default" : "outline"}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => setActiveStep(step.id)}>
|
||||||
|
<step.icon className="h-4 w-4" />
|
||||||
|
<span>{step.title}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Progress value={(activeStep / steps.length) * 100} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{/* --- Left Column: Property Details & Interaction --- */}
|
||||||
|
<div className="space-y-6 md:sticky md:top-6">
|
||||||
|
{" "}
|
||||||
|
{/* Make left column sticky */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Property Details</CardTitle>
|
||||||
|
<CardDescription>{propertyDetails.address}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
{/* Dynamically display details */}
|
||||||
|
<DetailRow label="Type" value={propertyDetails.type} />
|
||||||
|
<DetailRow label="Size" value={`${propertySize} sqm`} />
|
||||||
|
{propertyDetails.bedrooms && <DetailRow label="Bedrooms" value={propertyDetails.bedrooms} />}
|
||||||
|
{propertyDetails.bathrooms && <DetailRow label="Bathrooms" value={propertyDetails.bathrooms} />}
|
||||||
|
<DetailRow label="Age" value={`${propertyAge} years`} />
|
||||||
|
{propertyDetails.floor && <DetailRow label="Floor" value={propertyDetails.floor} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adjust Parameters</CardTitle>
|
||||||
|
<CardDescription>See how changes affect the prediction</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Size Slider */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label htmlFor="prop-size-slider" className="text-sm font-medium">
|
||||||
|
Property Size
|
||||||
|
</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">{propertySize} sqm</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="prop-size-slider"
|
||||||
|
value={[propertySize]}
|
||||||
|
min={50} // Example range
|
||||||
|
max={300} // Example range
|
||||||
|
step={5}
|
||||||
|
onValueChange={(value) => setPropertySize(value[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Age Slider */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label htmlFor="prop-age-slider" className="text-sm font-medium">
|
||||||
|
Property Age
|
||||||
|
</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">{propertyAge} years</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="prop-age-slider"
|
||||||
|
value={[propertyAge]}
|
||||||
|
min={0} // Example range
|
||||||
|
max={50} // Example range
|
||||||
|
step={1}
|
||||||
|
onValueChange={(value) => setPropertyAge(value[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<span className="text-sm text-muted-foreground">Adjusted Price</span>
|
||||||
|
<span className="text-xl font-bold">{formatCurrency(adjustedPrice)}</span>
|
||||||
|
</div>
|
||||||
|
{/* Show difference */}
|
||||||
|
{propertyDetails.predictedPrice !== adjustedPrice && (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{adjustedPrice > propertyDetails.predictedPrice ? "↑" : "↓"}
|
||||||
|
{Math.abs(adjustedPrice - propertyDetails.predictedPrice).toLocaleString()} THB from original
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Right Column: Step Content --- */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{activeStep === 1 && <Step1Content propertyDetails={propertyDetails} setActiveStep={setActiveStep} />}
|
||||||
|
{activeStep === 2 && <Step2Content features={features} setActiveStep={setActiveStep} />}
|
||||||
|
{activeStep === 3 && (
|
||||||
|
<Step3Content
|
||||||
|
property={propertyDetails}
|
||||||
|
comparisons={similarProperties}
|
||||||
|
setActiveStep={setActiveStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeStep === 4 && <Step4Content factors={environmentalFactors} setActiveStep={setActiveStep} />}
|
||||||
|
{activeStep === 5 && (
|
||||||
|
<Step5Content
|
||||||
|
predictedPrice={adjustedPrice}
|
||||||
|
confidence={confidence}
|
||||||
|
priceRange={priceRange}
|
||||||
|
setActiveStep={setActiveStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Components for Steps ---
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number): string {
|
||||||
|
return new Intl.NumberFormat("th-TH", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "THB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 Component
|
||||||
|
function Step1Content({
|
||||||
|
propertyDetails,
|
||||||
|
setActiveStep,
|
||||||
|
}: {
|
||||||
|
propertyDetails: PropertyBaseDetails;
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Property Overview</CardTitle>
|
||||||
|
<CardDescription>Basic information used in our prediction model</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
Our AI model begins by analyzing the core attributes of your property. These fundamental characteristics
|
||||||
|
form the baseline for our prediction.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<InfoCard
|
||||||
|
icon={Home}
|
||||||
|
title="Property Type"
|
||||||
|
description={`${propertyDetails.type} properties in this area have specific market dynamics`}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={Ruler}
|
||||||
|
title="Size & Layout"
|
||||||
|
description={`${propertyDetails.size} sqm${
|
||||||
|
propertyDetails.bedrooms ? ` with ${propertyDetails.bedrooms} beds` : ""
|
||||||
|
}${propertyDetails.bathrooms ? ` and ${propertyDetails.bathrooms} baths` : ""}`}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={Calendar}
|
||||||
|
title="Property Age"
|
||||||
|
description={`Built ${propertyDetails.age} years ago, affecting depreciation calculations`}
|
||||||
|
/>
|
||||||
|
{propertyDetails.floor && (
|
||||||
|
<InfoCard
|
||||||
|
icon={Building}
|
||||||
|
title="Floor & View"
|
||||||
|
description={`Located on floor ${propertyDetails.floor}, impacting value`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<StepFooter onNext={() => setActiveStep(2)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 Component
|
||||||
|
function Step2Content({
|
||||||
|
features,
|
||||||
|
setActiveStep,
|
||||||
|
}: {
|
||||||
|
features: ModelExplanationData["features"];
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Feature Analysis</CardTitle>
|
||||||
|
<CardDescription>How different features impact the predicted price</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p>
|
||||||
|
Our model analyzes various features and determines how each contributes to the price prediction. Below is a
|
||||||
|
breakdown of the most important factors.
|
||||||
|
</p>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<FeatureImportanceChart features={features} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature.name} className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm font-medium">{feature.name}</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
feature.impact === "positive"
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: feature.impact === "negative"
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: "text-yellow-600 dark:text-yellow-400"
|
||||||
|
}`}>
|
||||||
|
{feature.impact === "positive"
|
||||||
|
? "↑ Positive"
|
||||||
|
: feature.impact === "negative"
|
||||||
|
? "↓ Negative"
|
||||||
|
: "→ Neutral"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={feature.importance}
|
||||||
|
className="h-2"
|
||||||
|
aria-label={`${feature.name} importance ${feature.importance}%`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{feature.importance}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{feature.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<StepFooter onPrev={() => setActiveStep(1)} onNext={() => setActiveStep(3)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 Component
|
||||||
|
function Step3Content({
|
||||||
|
property,
|
||||||
|
comparisons,
|
||||||
|
setActiveStep,
|
||||||
|
}: {
|
||||||
|
property: PropertyBaseDetails;
|
||||||
|
comparisons: ModelExplanationData["similarProperties"];
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
}) {
|
||||||
|
// Prepare data for the chart, ensuring the main property is clearly labeled
|
||||||
|
const chartProperty = {
|
||||||
|
name: "Your Property",
|
||||||
|
price: property.predictedPrice,
|
||||||
|
size: property.size,
|
||||||
|
age: property.age,
|
||||||
|
};
|
||||||
|
const chartComparisons = comparisons.map((p, i) => ({
|
||||||
|
name: `Comp ${i + 1}`,
|
||||||
|
address: p.address,
|
||||||
|
price: p.price,
|
||||||
|
size: p.size,
|
||||||
|
age: p.age,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Market Comparison</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
How your property compares to similar properties recently analyzed or sold in the area
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p>
|
||||||
|
We analyze recent data from similar properties to establish a baseline. This ensures our prediction aligns
|
||||||
|
with current market conditions.
|
||||||
|
</p>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<PriceComparisonChart property={chartProperty} comparisons={chartComparisons} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium">Similar Properties Details</h4>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{comparisons.map((p, index) => (
|
||||||
|
<div key={index} className="rounded-lg border p-3 text-xs">
|
||||||
|
<div className="font-medium truncate" title={p.address}>
|
||||||
|
{p.address}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-muted-foreground">
|
||||||
|
{p.size} sqm, {p.age} years old
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 font-bold">{formatCurrency(p.price)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<StepFooter onPrev={() => setActiveStep(2)} onNext={() => setActiveStep(4)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 Component
|
||||||
|
function Step4Content({
|
||||||
|
factors,
|
||||||
|
setActiveStep,
|
||||||
|
}: {
|
||||||
|
factors: ModelExplanationData["environmentalFactors"];
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
}) {
|
||||||
|
const factorDetails = {
|
||||||
|
floodRisk: {
|
||||||
|
icon: Droplets,
|
||||||
|
color: factors.floodRisk === "low" ? "green" : factors.floodRisk === "moderate" ? "yellow" : "red",
|
||||||
|
text: "Historical data suggests this level of risk.",
|
||||||
|
},
|
||||||
|
airQuality: {
|
||||||
|
icon: Wind,
|
||||||
|
color: factors.airQuality === "good" ? "green" : factors.airQuality === "moderate" ? "yellow" : "red",
|
||||||
|
text: "Compared to city average.",
|
||||||
|
},
|
||||||
|
noiseLevel: {
|
||||||
|
icon: Sun,
|
||||||
|
color: factors.noiseLevel === "low" ? "green" : factors.noiseLevel === "moderate" ? "yellow" : "red",
|
||||||
|
text: "Based on proximity to major roads/sources.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Environmental & Location Factors</CardTitle>
|
||||||
|
<CardDescription>How surrounding conditions and amenities affect value</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p>
|
||||||
|
Environmental conditions and nearby amenities significantly impact desirability and value. Our model
|
||||||
|
considers these external factors.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{/* Environmental Factors */}
|
||||||
|
<FactorCard title="Flood Risk" factor={factors.floodRisk} details={factorDetails.floodRisk} />
|
||||||
|
<FactorCard title="Air Quality" factor={factors.airQuality} details={factorDetails.airQuality} />
|
||||||
|
<FactorCard title="Noise Level" factor={factors.noiseLevel} details={factorDetails.noiseLevel} />
|
||||||
|
</div>
|
||||||
|
{/* Proximity Example */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">Proximity to Amenities</h4>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<ProximityItem icon={Car} text="Public Transport: 300m" />
|
||||||
|
<ProximityItem icon={School} text="Schools: 1.2km" />
|
||||||
|
<ProximityItem icon={ShoppingBag} text="Shopping: 500m" />
|
||||||
|
<ProximityItem icon={Building} text="Hospitals: 2.5km" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<StepFooter onPrev={() => setActiveStep(3)} onNext={() => setActiveStep(5)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 Component
|
||||||
|
function Step5Content({
|
||||||
|
predictedPrice,
|
||||||
|
confidence,
|
||||||
|
priceRange,
|
||||||
|
setActiveStep,
|
||||||
|
}: {
|
||||||
|
predictedPrice: number;
|
||||||
|
confidence: number;
|
||||||
|
priceRange: { lower: number; upper: number };
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Final Prediction</CardTitle>
|
||||||
|
<CardDescription>The AI-predicted price based on all analyzed factors</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Price Box */}
|
||||||
|
<div className="rounded-lg bg-muted p-6 text-center">
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground">Predicted Price</h3>
|
||||||
|
<div className="mt-2 text-4xl font-bold">{formatCurrency(predictedPrice)}</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">Confidence Level: {(confidence * 100).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
{/* Price Range Box */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="font-medium flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4 text-primary" /> Price Range
|
||||||
|
</h4>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Based on our model's confidence, the likely market range is:
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Lower Bound</div>
|
||||||
|
<div className="text-muted-foreground">{formatCurrency(priceRange.lower)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-medium">Prediction</div>
|
||||||
|
<div className="text-primary font-bold">{formatCurrency(predictedPrice)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-medium">Upper Bound</div>
|
||||||
|
<div className="text-muted-foreground">{formatCurrency(priceRange.upper)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">Summary of Factors</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">This prediction considers:</p>
|
||||||
|
<ul className="mt-2 space-y-1 text-sm list-disc list-inside">
|
||||||
|
<li>Property characteristics (size, age, layout)</li>
|
||||||
|
<li>Location and neighborhood profile</li>
|
||||||
|
<li>Recent market trends and comparable sales</li>
|
||||||
|
<li>Environmental factors and amenity access</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setActiveStep(4)}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Link href="/map">
|
||||||
|
<Button variant="default">Back to Map</Button>
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-components for Steps ---
|
||||||
|
function InfoCard({ icon: Icon, title, description }: { icon: React.ElementType; title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||||
|
<Icon className="mt-0.5 h-5 w-5 text-primary flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm">{title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FactorCard({
|
||||||
|
title,
|
||||||
|
factor,
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
factor: string;
|
||||||
|
details: { icon: React.ElementType; color: string; text: string };
|
||||||
|
}) {
|
||||||
|
const Icon = details.icon;
|
||||||
|
const colorClass = `bg-${details.color}-500`; // Requires Tailwind JIT or safelisting
|
||||||
|
const textColorClass = `text-${details.color}-500`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border p-4 text-center">
|
||||||
|
<Icon className={`h-8 w-8 mb-2 ${textColorClass}`} />
|
||||||
|
<h4 className="font-medium text-sm">{title}</h4>
|
||||||
|
<div className={`mt-2 flex items-center gap-2`}>
|
||||||
|
{/* Explicit colors might be safer than dynamic Tailwind classes */}
|
||||||
|
<div
|
||||||
|
className={`h-3 w-3 rounded-full`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: details.color === "green" ? "#22c55e" : details.color === "yellow" ? "#eab308" : "#ef4444",
|
||||||
|
}}></div>
|
||||||
|
<span className="text-sm capitalize">{factor}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{details.text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProximityItem({ icon: Icon, text }: { icon: React.ElementType; text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border p-2 text-xs">
|
||||||
|
<Icon className="h-4 w-4 text-primary flex-shrink-0" />
|
||||||
|
<div>{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepFooter({ onPrev, onNext }: { onPrev?: () => void; onNext?: () => void }) {
|
||||||
|
return (
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
{onPrev ? (
|
||||||
|
<Button variant="outline" onClick={onPrev}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
{onNext ? (
|
||||||
|
<Button onClick={onNext}>
|
||||||
|
Next Step <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/app/(routes)/map/layout.tsx
Normal file
21
frontend/app/(routes)/map/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/app/(routes)/map/layout.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import type React from "react";
|
||||||
|
// import { PageLayout } from "@/components/common/PageLayout"; // Example using a common layout
|
||||||
|
|
||||||
|
// This layout is specific to the map feature's route group
|
||||||
|
export default function MapFeatureLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
// <PageLayout className="flex flex-row"> {/* Example using common layout */}
|
||||||
|
// The MapSidebar might be rendered here if it's part of the layout
|
||||||
|
<div className="relative flex-1 h-full w-full">
|
||||||
|
{" "}
|
||||||
|
{/* Ensure content takes up space */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
// </PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/app/(routes)/map/page.tsx
Normal file
73
frontend/app/(routes)/map/page.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/app/(routes)/map/page.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
// Import common components
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
// NOTE: ThemeProvider and ThemeController are in the root layout or a higher common layout now
|
||||||
|
|
||||||
|
// Import feature-specific components/contexts/types
|
||||||
|
import { MapContainer } from "@/features/map/components/map-container";
|
||||||
|
// MapSidebar might be part of the layout now, if shared, otherwise import here
|
||||||
|
// import { MapSidebar } from "@/features/map/components/map-sidebar";
|
||||||
|
import { MapHeader } from "@/features/map/components/map-header"; // Map specific header
|
||||||
|
import { OverlayProvider } from "@/features/map/components/overlay-system/overlay-context";
|
||||||
|
import { OverlayDock } from "@/features/map/components/overlay-system/overlay-dock";
|
||||||
|
import { AnalyticsOverlay } from "@/features/map/components/analytics-overlay";
|
||||||
|
import { FiltersOverlay } from "@/features/map/components/filters-overlay";
|
||||||
|
import { ChatOverlay } from "@/features/map/components/chat-overlay";
|
||||||
|
import type { MapLocation } from "@/features/map/types";
|
||||||
|
|
||||||
|
export default function MapPage() {
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<MapLocation>({
|
||||||
|
lat: 13.7563,
|
||||||
|
lng: 100.5018,
|
||||||
|
name: "Bangkok",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main page structure remains similar, but imports are updated
|
||||||
|
return (
|
||||||
|
// ThemeProvider/Controller likely moved to root layout
|
||||||
|
// SidebarProvider might be moved too, depending on its scope
|
||||||
|
// Assuming OverlayProvider is specific to this map page context
|
||||||
|
<OverlayProvider>
|
||||||
|
{/* The outer div with flex, h-screen etc. should be handled by the layout file or a common PageLayout */}
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
{" "}
|
||||||
|
{/* Simplified for page content */}
|
||||||
|
<MapHeader />
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<MapContainer selectedLocation={selectedLocation} />
|
||||||
|
|
||||||
|
{/* Prediction model banner */}
|
||||||
|
<div className="absolute left-1/2 top-4 -translate-x-1/2 z-10">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-card/95 backdrop-blur-sm border border-border/50 px-4 py-2 shadow-lg">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Price Prediction: 15,000,000 ฿</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Based on our AI model analysis</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/model-explanation">
|
||||||
|
<Button size="sm" variant="outline" className="gap-1">
|
||||||
|
Explain <ArrowRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay System */}
|
||||||
|
<AnalyticsOverlay />
|
||||||
|
<FiltersOverlay />
|
||||||
|
<ChatOverlay />
|
||||||
|
<OverlayDock position="bottom" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OverlayProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,31 +1,131 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss" prefix(tw);
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
|
--color-sidebar: hsl(var(--sidebar-background));
|
||||||
|
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||||
|
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||||
|
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||||
|
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||||
|
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||||
|
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||||
|
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 0 0% 3.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 0 0% 3.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 0 0% 3.9%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 0 0% 9%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 0 0% 9%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 0 0% 45.1%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 0 0% 96.1%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 0 0% 9%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 0 0% 89.8%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 0 0% 89.8%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Sidebar specific colors */
|
|
||||||
--sidebar-background: 0 0% 98%;
|
--sidebar-background: 0 0% 98%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
@ -34,38 +134,36 @@
|
|||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
/* Overlay constraints */
|
|
||||||
--max-overlay-width: calc(100vw - 32px);
|
|
||||||
--max-overlay-height: calc(100vh - 32px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 0 0% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 0 0% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 0 0% 3.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 0 0% 9%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 0 0% 14.9%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 0 0% 14.9%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 0 0% 63.9%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 0 0% 14.9%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 0 0% 14.9%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 0 0% 14.9%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
/* Sidebar specific colors for dark mode */
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
--sidebar-background: 240 5.9% 10%;
|
--sidebar-background: 240 5.9% 10%;
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-primary: 0 0% 98%;
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
--sidebar-primary-foreground: 240 5.9% 10%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
@ -81,25 +179,3 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.overlay-container {
|
|
||||||
@apply absolute z-20;
|
|
||||||
max-width: var(--max-overlay-width);
|
|
||||||
max-height: var(--max-overlay-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-container[data-minimized="true"] {
|
|
||||||
@apply z-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-card {
|
|
||||||
@apply bg-card/95 backdrop-blur-sm border border-border/50 shadow-lg;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-minimized {
|
|
||||||
@apply w-[200px] h-auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,20 +1,46 @@
|
|||||||
import type { Metadata } from 'next'
|
/*
|
||||||
import './globals.css'
|
========================================
|
||||||
|
File: frontend/app/layout.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google"; // Example using Inter font
|
||||||
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/components/common/ThemeProvider"; // Correct path
|
||||||
|
import { ThemeController } from "@/components/common/ThemeController"; // Correct path
|
||||||
|
import { Toaster as SonnerToaster } from "@/components/ui/sonner"; // Sonner for notifications
|
||||||
|
import { Toaster as RadixToaster } from "@/components/ui/toaster"; // Shadcn Toaster (if using useToast hook)
|
||||||
|
|
||||||
|
// Setup font
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); // Define CSS variable
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'v0 App',
|
title: "Borbann - Data Platform", // More specific title
|
||||||
description: 'Created with v0',
|
description: "Data integration, analysis, and visualization platform.",
|
||||||
generator: 'v0.dev',
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>{children}</body>
|
{" "}
|
||||||
|
{/* suppressHydrationWarning needed for next-themes */}
|
||||||
|
<body className={`${inter.variable} font-sans`}>
|
||||||
|
{" "}
|
||||||
|
{/* Apply font class */}
|
||||||
|
{/* ThemeProvider should wrap everything for theme context */}
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
|
{/* ThemeController can wrap specific parts or the whole app */}
|
||||||
|
{/* If placed here, it controls the base theme and color scheme */}
|
||||||
|
<ThemeController defaultColorScheme="Blue">{children}</ThemeController>
|
||||||
|
{/* Include Toaster components for notifications */}
|
||||||
|
<SonnerToaster />
|
||||||
|
<RadixToaster /> {/* Include if using the useToast hook */}
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Bot, LineChart, Wind, Droplets, Sparkles, Maximize2, Minimize2, ArrowLeftRight } from "lucide-react"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { AreaChart } from "./area-chart"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useOverlayContext } from "./overlay-context"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
|
|
||||||
interface AnalyticsPanelProps {
|
|
||||||
onChatClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalyticsPanel({ onChatClick }: AnalyticsPanelProps) {
|
|
||||||
const { overlays, minimizeOverlay, maximizeOverlay, changePosition } = useOverlayContext()
|
|
||||||
const isMinimized = overlays.analytics.minimized
|
|
||||||
const position = overlays.analytics.position
|
|
||||||
|
|
||||||
if (isMinimized) {
|
|
||||||
return (
|
|
||||||
<Card className="overlay-card overlay-minimized">
|
|
||||||
<CardHeader className="p-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium">Analytics</CardTitle>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("analytics")}>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="overlay-card w-[350px] max-h-[80vh] overflow-hidden">
|
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
Analytics
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => changePosition("analytics", position === "right" ? "left" : "right")}
|
|
||||||
title={`Move to ${position === "right" ? "left" : "right"}`}
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("analytics")}>
|
|
||||||
<Minimize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<ScrollArea className="h-full max-h-[calc(min(80vh,var(--max-overlay-height))-60px)]">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Information in radius will be analyzed</p>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<LineChart className="h-4 w-4 text-primary" />
|
|
||||||
Area Price History
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div className="text-2xl font-bold">10,000,000 Baht</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Overall Price History of this area</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<AreaChart
|
|
||||||
data={[8500000, 9000000, 8800000, 9200000, 9500000, 9800000, 10000000]}
|
|
||||||
color="rgba(59, 130, 246, 0.5)"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<LineChart className="h-4 w-4 text-primary" />
|
|
||||||
Price Prediction
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div className="text-2xl font-bold">15,000,000 Baht</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">The estimated price based on various factors.</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<AreaChart
|
|
||||||
data={[10000000, 11000000, 12000000, 13000000, 14000000, 14500000, 15000000]}
|
|
||||||
color="rgba(16, 185, 129, 0.5)"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Droplets className="h-4 w-4 text-blue-500" />
|
|
||||||
Flood Factor
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
|
||||||
<span className="text-sm">Moderate</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Wind className="h-4 w-4 text-purple-500" />
|
|
||||||
Air Factor
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
|
||||||
<span className="text-sm">Bad</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
className="bg-card/50 border border-border/50 shadow-sm cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={onChatClick}
|
|
||||||
>
|
|
||||||
<CardHeader className="p-4">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Bot className="h-4 w-4 text-teal-500" />
|
|
||||||
Chat With AI
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground">Want to ask specific question?</p>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</ScrollArea>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Send, X, Minimize2, Maximize2 } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useOverlayContext } from "./overlay-context"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
|
|
||||||
interface ChatBotProps {
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatBot({ onClose }: ChatBotProps) {
|
|
||||||
const { overlays, minimizeOverlay, maximizeOverlay } = useOverlayContext()
|
|
||||||
const isMinimized = overlays.chat.minimized
|
|
||||||
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
const [chatHistory, setChatHistory] = useState([{ role: "bot", content: "Hi! How can I help you today?" }])
|
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
|
||||||
if (!message.trim()) return
|
|
||||||
|
|
||||||
// Add user message to chat
|
|
||||||
setChatHistory([...chatHistory, { role: "user", content: message }])
|
|
||||||
|
|
||||||
// Simulate bot response (in a real app, this would call an API)
|
|
||||||
setTimeout(() => {
|
|
||||||
setChatHistory((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: "bot",
|
|
||||||
content: "I can provide information about this area. What would you like to know?",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
setMessage("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMinimized) {
|
|
||||||
return (
|
|
||||||
<Card className="overlay-card overlay-minimized">
|
|
||||||
<CardHeader className="p-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium">ChatBot</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("chat")}>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="overlay-card w-[400px] max-h-[80vh] overflow-hidden">
|
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium">ChatBot</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("chat")}>
|
|
||||||
<Minimize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0 flex flex-col h-[min(400px,calc(var(--max-overlay-height)-60px))]">
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{chatHistory.map((chat, index) => (
|
|
||||||
<div key={index} className={`flex ${chat.role === "user" ? "justify-end" : "justify-start"}`}>
|
|
||||||
<div
|
|
||||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
|
||||||
chat.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{chat.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<div className="border-t p-4 flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
className="flex-1"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSendMessage()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button size="icon" onClick={handleSendMessage}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Send, MessageCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Overlay } from "./overlay-system/overlay"
|
|
||||||
|
|
||||||
export function ChatOverlay() {
|
|
||||||
const [message, setMessage] = useState("")
|
|
||||||
const [chatHistory, setChatHistory] = useState([{ role: "bot", content: "Hi! How can I help you today?" }])
|
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
|
||||||
if (!message.trim()) return
|
|
||||||
|
|
||||||
// Add user message to chat
|
|
||||||
setChatHistory([...chatHistory, { role: "user", content: message }])
|
|
||||||
|
|
||||||
// Simulate bot response (in a real app, this would call an API)
|
|
||||||
setTimeout(() => {
|
|
||||||
setChatHistory((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: "bot",
|
|
||||||
content: "I can provide information about this area. What would you like to know?",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
setMessage("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Overlay
|
|
||||||
id="chat"
|
|
||||||
title="Chat Assistant"
|
|
||||||
icon={<MessageCircle className="h-5 w-5" />}
|
|
||||||
initialPosition="bottom-right"
|
|
||||||
initialIsOpen={false}
|
|
||||||
width="400px"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col h-[400px]">
|
|
||||||
<div className="flex-1 p-4 overflow-auto">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{chatHistory.map((chat, index) => (
|
|
||||||
<div key={index} className={`flex ${chat.role === "user" ? "justify-end" : "justify-start"}`}>
|
|
||||||
<div
|
|
||||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
|
||||||
chat.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{chat.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t p-4 flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Type your message..."
|
|
||||||
className="flex-1"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSendMessage()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button size="icon" onClick={handleSendMessage}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Filter } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Slider } from "@/components/ui/slider"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Overlay } from "./overlay-system/overlay"
|
|
||||||
|
|
||||||
export function FiltersOverlay() {
|
|
||||||
const [area, setArea] = useState("< 30 km")
|
|
||||||
const [timePeriod, setTimePeriod] = useState("All Time")
|
|
||||||
const [propertyType, setPropertyType] = useState("House")
|
|
||||||
const [priceRange, setPriceRange] = useState([5000000, 20000000])
|
|
||||||
const [activeTab, setActiveTab] = useState("basic")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Overlay
|
|
||||||
id="filters"
|
|
||||||
title="Property Filters"
|
|
||||||
icon={<Filter className="h-5 w-5" />}
|
|
||||||
initialPosition="bottom-left"
|
|
||||||
initialIsOpen={true}
|
|
||||||
width="350px"
|
|
||||||
>
|
|
||||||
<div className="h-[calc(min(70vh,500px))] overflow-auto p-4">
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
|
||||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
|
||||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="basic" className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium">Area Radius</label>
|
|
||||||
<Select value={area} onValueChange={setArea}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select area" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="< 10 km">{"< 10 km"}</SelectItem>
|
|
||||||
<SelectItem value="< 20 km">{"< 20 km"}</SelectItem>
|
|
||||||
<SelectItem value="< 30 km">{"< 30 km"}</SelectItem>
|
|
||||||
<SelectItem value="< 50 km">{"< 50 km"}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium">Time Period</label>
|
|
||||||
<Select value={timePeriod} onValueChange={setTimePeriod}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select time period" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Last Month">Last Month</SelectItem>
|
|
||||||
<SelectItem value="Last 3 Months">Last 3 Months</SelectItem>
|
|
||||||
<SelectItem value="Last Year">Last Year</SelectItem>
|
|
||||||
<SelectItem value="All Time">All Time</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-medium">Property Type</label>
|
|
||||||
<Select value={propertyType} onValueChange={setPropertyType}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select property type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="House">House</SelectItem>
|
|
||||||
<SelectItem value="Condo">Condo</SelectItem>
|
|
||||||
<SelectItem value="Townhouse">Townhouse</SelectItem>
|
|
||||||
<SelectItem value="Land">Land</SelectItem>
|
|
||||||
<SelectItem value="Commercial">Commercial</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="advanced" className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between mb-1">
|
|
||||||
<label className="text-xs font-medium">Price Range</label>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(priceRange[0])}{" "}
|
|
||||||
-{" "}
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(priceRange[1])}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider value={priceRange} min={1000000} max={50000000} step={1000000} onValueChange={setPriceRange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium">Environmental Factors</label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="low-flood" className="text-xs">
|
|
||||||
Low Flood Risk
|
|
||||||
</Label>
|
|
||||||
<Switch id="low-flood" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="good-air" className="text-xs">
|
|
||||||
Good Air Quality
|
|
||||||
</Label>
|
|
||||||
<Switch id="good-air" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="low-noise" className="text-xs">
|
|
||||||
Low Noise Pollution
|
|
||||||
</Label>
|
|
||||||
<Switch id="low-noise" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Button className="mt-4 w-full" size="sm">
|
|
||||||
Apply Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react"
|
|
||||||
|
|
||||||
interface MapContainerProps {
|
|
||||||
selectedLocation: {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapContainer({ selectedLocation }: MapContainerProps) {
|
|
||||||
const mapRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// This is a placeholder for actual map integration
|
|
||||||
// In a real application, you would use a library like Google Maps, Mapbox, or Leaflet
|
|
||||||
const mapElement = mapRef.current
|
|
||||||
|
|
||||||
if (mapElement) {
|
|
||||||
// Simulate map loading with a background image
|
|
||||||
mapElement.style.backgroundImage = "url('/placeholder.svg?height=800&width=1200')"
|
|
||||||
mapElement.style.backgroundSize = "cover"
|
|
||||||
mapElement.style.backgroundPosition = "center"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up function
|
|
||||||
return () => {
|
|
||||||
if (mapElement) {
|
|
||||||
mapElement.style.backgroundImage = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedLocation])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={mapRef} className="absolute inset-0 h-full w-full bg-muted/20 dark:bg-muted/10">
|
|
||||||
{/* Map markers would be rendered here in a real implementation */}
|
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<div className="h-6 w-6 animate-pulse rounded-full bg-red-500 ring-4 ring-red-300 dark:ring-red-800"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Home,
|
|
||||||
Clock,
|
|
||||||
Map,
|
|
||||||
FileText,
|
|
||||||
Settings,
|
|
||||||
PenTool,
|
|
||||||
BarChart3,
|
|
||||||
Plane,
|
|
||||||
LineChart,
|
|
||||||
DollarSign,
|
|
||||||
MoreHorizontal,
|
|
||||||
} from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
|
|
||||||
export function MapSidebar() {
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
const mainNavItems = [
|
|
||||||
{ name: "Home", icon: Home, href: "/" },
|
|
||||||
{ name: "My assets", icon: Clock, href: "/assets" },
|
|
||||||
{ name: "Models", icon: Map, href: "/models" },
|
|
||||||
{ name: "Trade", icon: LineChart, href: "/trade" },
|
|
||||||
{ name: "Earn", icon: DollarSign, href: "/earn" },
|
|
||||||
{ name: "Documentation", icon: FileText, href: "/documentation", badge: "NEW" },
|
|
||||||
{ name: "Pay", icon: Settings, href: "/pay" },
|
|
||||||
{ name: "More", icon: MoreHorizontal, href: "/more" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const projectNavItems = [
|
|
||||||
{ name: "Design Engineering", icon: PenTool, href: "/projects/design" },
|
|
||||||
{ name: "Sales & Marketing", icon: BarChart3, href: "/projects/sales" },
|
|
||||||
{ name: "Travel", icon: Plane, href: "/projects/travel" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="flex h-full w-[240px] flex-col border-r bg-card dark:bg-card">
|
|
||||||
<div className="flex h-14 items-center border-b px-4">
|
|
||||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
B
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold">BorBann</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col p-2">
|
|
||||||
<nav className="space-y-1">
|
|
||||||
{mainNavItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
||||||
pathname === item.href ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<item.icon className="h-5 w-5" />
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
{item.badge && (
|
|
||||||
<span className="rounded bg-primary/20 px-1.5 py-0.5 text-xs font-semibold text-primary">
|
|
||||||
{item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg bg-muted/50 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/20 text-primary">
|
|
||||||
<Gift className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Get ฿10</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Invite friends</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto border-t p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs">
|
|
||||||
GG
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">GG_WPX</div>
|
|
||||||
<div className="text-xs text-muted-foreground">garfield.wpx@gmail.com</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Gift(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
{...props}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
|
||||||
<rect x="2" y="7" width="20" height="5"></rect>
|
|
||||||
<line x1="12" y1="22" x2="12" y2="7"></line>
|
|
||||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
|
||||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, type ReactNode } from "react"
|
|
||||||
|
|
||||||
type OverlayType = "analytics" | "filters" | "chat"
|
|
||||||
type OverlayPosition = "left" | "right"
|
|
||||||
|
|
||||||
interface OverlayState {
|
|
||||||
visible: boolean
|
|
||||||
minimized: boolean
|
|
||||||
position: OverlayPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverlayContextType {
|
|
||||||
overlays: Record<OverlayType, OverlayState>
|
|
||||||
toggleOverlay: (type: OverlayType) => void
|
|
||||||
minimizeOverlay: (type: OverlayType) => void
|
|
||||||
maximizeOverlay: (type: OverlayType) => void
|
|
||||||
changePosition: (type: OverlayType, position: OverlayPosition) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const OverlayContext = createContext<OverlayContextType | undefined>(undefined)
|
|
||||||
|
|
||||||
export function OverlayProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [overlays, setOverlays] = useState<Record<OverlayType, OverlayState>>({
|
|
||||||
analytics: { visible: true, minimized: false, position: "right" },
|
|
||||||
filters: { visible: true, minimized: false, position: "left" },
|
|
||||||
chat: { visible: false, minimized: false, position: "right" },
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleOverlay = (type: OverlayType) => {
|
|
||||||
setOverlays((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type]: {
|
|
||||||
...prev[type],
|
|
||||||
visible: !prev[type].visible,
|
|
||||||
minimized: false,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const minimizeOverlay = (type: OverlayType) => {
|
|
||||||
setOverlays((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type]: {
|
|
||||||
...prev[type],
|
|
||||||
minimized: true,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const maximizeOverlay = (type: OverlayType) => {
|
|
||||||
setOverlays((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type]: {
|
|
||||||
...prev[type],
|
|
||||||
minimized: false,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const changePosition = (type: OverlayType, position: OverlayPosition) => {
|
|
||||||
setOverlays((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type]: {
|
|
||||||
...prev[type],
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverlayContext.Provider
|
|
||||||
value={{
|
|
||||||
overlays,
|
|
||||||
toggleOverlay,
|
|
||||||
minimizeOverlay,
|
|
||||||
maximizeOverlay,
|
|
||||||
changePosition,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</OverlayContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOverlayContext() {
|
|
||||||
const context = useContext(OverlayContext)
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useOverlayContext must be used within an OverlayProvider")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { MessageCircle, Filter, Layers, ArrowLeftRight } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import { useOverlayContext } from "./overlay-context"
|
|
||||||
|
|
||||||
export function OverlayControls() {
|
|
||||||
const { overlays, toggleOverlay, changePosition } = useOverlayContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className="fixed bottom-4 right-4 flex flex-col gap-2 z-50">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
|
||||||
onClick={() => toggleOverlay("analytics")}
|
|
||||||
>
|
|
||||||
<Layers className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
{overlays.analytics.visible ? "Hide analytics" : "Show analytics"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
|
||||||
onClick={() => toggleOverlay("filters")}
|
|
||||||
>
|
|
||||||
<Filter className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">{overlays.filters.visible ? "Hide filters" : "Show filters"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
|
||||||
onClick={() => changePosition("analytics", overlays.analytics.position === "right" ? "left" : "right")}
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
Move analytics to the {overlays.analytics.position === "right" ? "left" : "right"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{!overlays.chat.visible && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
|
||||||
onClick={() => toggleOverlay("chat")}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">Open chat</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useOverlayContext } from "./overlay-context"
|
|
||||||
import { AnalyticsPanel } from "./analytics-panel"
|
|
||||||
import { PropertyFilters } from "./property-filters"
|
|
||||||
import { ChatBot } from "./chat-bot"
|
|
||||||
import { OverlayControls } from "./overlay-controls"
|
|
||||||
|
|
||||||
export function OverlayManager() {
|
|
||||||
const { overlays, toggleOverlay } = useOverlayContext()
|
|
||||||
|
|
||||||
// Function to ensure overlays stay within viewport bounds
|
|
||||||
const getPositionClasses = (position: string, type: string) => {
|
|
||||||
if (position === "right") {
|
|
||||||
return "right-4"
|
|
||||||
} else if (position === "left") {
|
|
||||||
return "left-4"
|
|
||||||
}
|
|
||||||
return type === "analytics" ? "right-4" : "left-4"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Analytics Panel */}
|
|
||||||
{overlays.analytics.visible && (
|
|
||||||
<div
|
|
||||||
className={`overlay-container ${getPositionClasses(overlays.analytics.position, "analytics")} top-20`}
|
|
||||||
data-minimized={overlays.analytics.minimized}
|
|
||||||
>
|
|
||||||
<AnalyticsPanel onChatClick={() => toggleOverlay("chat")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Property Filters */}
|
|
||||||
{overlays.filters.visible && (
|
|
||||||
<div
|
|
||||||
className={`overlay-container ${getPositionClasses(overlays.filters.position, "filters")} bottom-4`}
|
|
||||||
data-minimized={overlays.filters.minimized}
|
|
||||||
>
|
|
||||||
<PropertyFilters />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat Bot */}
|
|
||||||
{overlays.chat.visible && (
|
|
||||||
<div
|
|
||||||
className={`overlay-container ${getPositionClasses(overlays.chat.position, "chat")} bottom-4`}
|
|
||||||
data-minimized={overlays.chat.minimized}
|
|
||||||
>
|
|
||||||
<ChatBot onClose={() => toggleOverlay("chat")} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Controls */}
|
|
||||||
<OverlayControls />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import { useOverlay } from "./overlay-context"
|
|
||||||
|
|
||||||
interface OverlayDockProps {
|
|
||||||
position?: "bottom" | "right"
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverlayDock({ position = "bottom", className }: OverlayDockProps) {
|
|
||||||
const { overlays, toggleOverlay } = useOverlay()
|
|
||||||
|
|
||||||
// Filter overlays that have icons
|
|
||||||
const overlaysWithIcons = Object.values(overlays).filter((overlay) => overlay.icon)
|
|
||||||
|
|
||||||
if (overlaysWithIcons.length === 0) return null
|
|
||||||
|
|
||||||
const positionClasses = {
|
|
||||||
bottom: "fixed bottom-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
|
|
||||||
right: "fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className={positionClasses[position]}>
|
|
||||||
{overlaysWithIcons.map((overlay) => (
|
|
||||||
<div key={overlay.id}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={overlay.isOpen ? "default" : "outline"}
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
|
||||||
onClick={() => toggleOverlay(overlay.id)}
|
|
||||||
>
|
|
||||||
{overlay.icon}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={position === "bottom" ? "top" : "left"}>
|
|
||||||
{overlay.isOpen ? `Hide ${overlay.title}` : `Show ${overlay.title}`}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useEffect, useState, useRef } from "react"
|
|
||||||
import { X, Minimize2, Maximize2, Move } from "lucide-react"
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useOverlay, type OverlayId, type OverlayPosition } from "./overlay-context"
|
|
||||||
|
|
||||||
interface OverlayProps {
|
|
||||||
id: OverlayId
|
|
||||||
title: string
|
|
||||||
icon?: React.ReactNode
|
|
||||||
initialPosition?: OverlayPosition
|
|
||||||
initialIsOpen?: boolean
|
|
||||||
className?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
onClose?: () => void
|
|
||||||
showMinimize?: boolean
|
|
||||||
width?: string
|
|
||||||
height?: string
|
|
||||||
maxHeight?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Overlay({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
initialPosition = "bottom-right",
|
|
||||||
initialIsOpen = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
onClose,
|
|
||||||
showMinimize = true,
|
|
||||||
width = "350px",
|
|
||||||
height = "auto",
|
|
||||||
maxHeight = "80vh",
|
|
||||||
}: OverlayProps) {
|
|
||||||
const { overlays, registerOverlay, unregisterOverlay, closeOverlay, minimizeOverlay, maximizeOverlay, bringToFront } =
|
|
||||||
useOverlay()
|
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Register overlay on mount
|
|
||||||
useEffect(() => {
|
|
||||||
registerOverlay(id, {
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
position: initialPosition,
|
|
||||||
isOpen: initialIsOpen,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Unregister on unmount
|
|
||||||
return () => unregisterOverlay(id)
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
// Get overlay state
|
|
||||||
const overlay = overlays[id]
|
|
||||||
if (!overlay) return null
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
closeOverlay(id)
|
|
||||||
if (onClose) onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMinimize = () => {
|
|
||||||
minimizeOverlay(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMaximize = () => {
|
|
||||||
maximizeOverlay(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHeaderClick = () => {
|
|
||||||
if (!isDragging) {
|
|
||||||
bringToFront(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position classes based on position
|
|
||||||
const positionClasses = {
|
|
||||||
"top-left": "top-4 left-4",
|
|
||||||
"top-right": "top-4 right-4",
|
|
||||||
"bottom-left": "bottom-4 left-4",
|
|
||||||
"bottom-right": "bottom-4 right-4",
|
|
||||||
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not open, don't render
|
|
||||||
if (!overlay.isOpen) return null
|
|
||||||
|
|
||||||
// Render minimized state
|
|
||||||
if (overlay.isMinimized) {
|
|
||||||
return (
|
|
||||||
<div className={cn("fixed z-10", positionClasses[overlay.position])} style={{ zIndex: overlay.zIndex }}>
|
|
||||||
<Card className="w-[200px] shadow-lg bg-card/95 backdrop-blur-sm border border-border/50">
|
|
||||||
<CardHeader className="p-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
{icon && <span className="text-primary">{icon}</span>}
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleMaximize}>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render full overlay
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
className={cn("fixed z-10", positionClasses[overlay.position])}
|
|
||||||
style={{ zIndex: overlay.zIndex }}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className={cn("shadow-lg bg-card/95 backdrop-blur-sm border border-border/50 overflow-hidden", className)}
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
maxHeight,
|
|
||||||
maxWidth: "calc(100vw - 32px)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between cursor-move" onClick={handleHeaderClick}>
|
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
||||||
{icon && <span className="text-primary">{icon}</span>}
|
|
||||||
{title}
|
|
||||||
<Move className="h-3 w-3 text-muted-foreground ml-1" />
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{showMinimize && (
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleMinimize}>
|
|
||||||
<Minimize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">{children}</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import type React from "react"
|
|
||||||
|
|
||||||
export default function MapLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { MapContainer } from "./components/map-container"
|
|
||||||
import { MapSidebar } from "./components/map-sidebar"
|
|
||||||
import { MapHeader } from "./components/map-header"
|
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ArrowRight } from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { OverlayProvider } from "./components/overlay-system/overlay-context"
|
|
||||||
import { OverlayDock } from "./components/overlay-system/overlay-dock"
|
|
||||||
import { AnalyticsOverlay } from "./components/analytics-overlay"
|
|
||||||
import { FiltersOverlay } from "./components/filters-overlay"
|
|
||||||
import { ChatOverlay } from "./components/chat-overlay"
|
|
||||||
import { ThemeController } from "@/components/theme-controller"
|
|
||||||
|
|
||||||
export default function MapPage() {
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<{
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
name?: string
|
|
||||||
}>({
|
|
||||||
lat: 13.7563,
|
|
||||||
lng: 100.5018,
|
|
||||||
name: "Bangkok",
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider defaultTheme="dark">
|
|
||||||
<SidebarProvider>
|
|
||||||
<OverlayProvider>
|
|
||||||
<ThemeController defaultColorScheme="Blue">
|
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
|
||||||
<MapSidebar />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<MapHeader />
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
|
||||||
<MapContainer selectedLocation={selectedLocation} />
|
|
||||||
|
|
||||||
{/* Prediction model banner */}
|
|
||||||
<div className="absolute left-1/2 top-4 -translate-x-1/2 z-10">
|
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-card/95 backdrop-blur-sm border border-border/50 px-4 py-2 shadow-lg">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Price Prediction: 15,000,000 ฿</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">Based on our AI model analysis</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/model-explanation">
|
|
||||||
<Button size="sm" variant="outline" className="gap-1">
|
|
||||||
Explain <ArrowRight className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlay System */}
|
|
||||||
<AnalyticsOverlay />
|
|
||||||
<FiltersOverlay />
|
|
||||||
<ChatOverlay />
|
|
||||||
<OverlayDock position="bottom" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ThemeController>
|
|
||||||
</OverlayProvider>
|
|
||||||
</SidebarProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "@/components/ui/chart"
|
|
||||||
|
|
||||||
interface Feature {
|
|
||||||
name: string
|
|
||||||
importance: number
|
|
||||||
value: string
|
|
||||||
impact: "positive" | "negative" | "neutral"
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeatureImportanceChartProps {
|
|
||||||
features: Feature[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeatureImportanceChart({ features }: FeatureImportanceChartProps) {
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const isDark = theme === "dark"
|
|
||||||
|
|
||||||
// Sort features by importance
|
|
||||||
const sortedFeatures = [...features].sort((a, b) => b.importance - a.importance)
|
|
||||||
|
|
||||||
const getBarColor = (impact: string) => {
|
|
||||||
if (impact === "positive") return "#10b981"
|
|
||||||
if (impact === "negative") return "#ef4444"
|
|
||||||
return "#f59e0b"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={sortedFeatures} layout="vertical" margin={{ top: 20, right: 30, left: 100, bottom: 5 }}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
horizontal={true}
|
|
||||||
vertical={false}
|
|
||||||
stroke={isDark ? "#374151" : "#e5e7eb"}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
domain={[0, 100]}
|
|
||||||
tickFormatter={(value) => `${value}%`}
|
|
||||||
stroke={isDark ? "#9ca3af" : "#6b7280"}
|
|
||||||
/>
|
|
||||||
<YAxis dataKey="name" type="category" width={90} stroke={isDark ? "#9ca3af" : "#6b7280"} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => [`${value}%`, "Importance"]}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: isDark ? "#1f2937" : "white",
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: isDark ? "#e5e7eb" : "#1f2937",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
|
|
||||||
{sortedFeatures.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={getBarColor(entry.impact)} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
Cell,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
|
|
||||||
interface PropertyData {
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
size: number
|
|
||||||
age: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PriceComparisonChartProps {
|
|
||||||
property: PropertyData
|
|
||||||
comparisons: PropertyData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriceComparisonChart({ property, comparisons }: PriceComparisonChartProps) {
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const isDark = theme === "dark"
|
|
||||||
|
|
||||||
// Combine property and comparisons
|
|
||||||
const data = [property, ...comparisons]
|
|
||||||
|
|
||||||
// Format the price for display
|
|
||||||
const formatPrice = (value: number) => {
|
|
||||||
return new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? "#374151" : "#e5e7eb"} />
|
|
||||||
<XAxis dataKey="name" stroke={isDark ? "#9ca3af" : "#6b7280"} />
|
|
||||||
<YAxis tickFormatter={(value) => `${(value / 1000000).toFixed(1)}M`} stroke={isDark ? "#9ca3af" : "#6b7280"} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => [formatPrice(value), "Price"]}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: isDark ? "#1f2937" : "white",
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: isDark ? "#e5e7eb" : "#1f2937",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="price" name="Price" radius={[4, 4, 0, 0]}>
|
|
||||||
{data.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={index === 0 ? "#3b82f6" : "#6b7280"}
|
|
||||||
fillOpacity={index === 0 ? 1 : 0.7}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,640 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
Info,
|
|
||||||
ArrowRight,
|
|
||||||
Home,
|
|
||||||
Building,
|
|
||||||
Ruler,
|
|
||||||
Calendar,
|
|
||||||
Coins,
|
|
||||||
Droplets,
|
|
||||||
Wind,
|
|
||||||
Sun,
|
|
||||||
Car,
|
|
||||||
School,
|
|
||||||
ShoppingBag,
|
|
||||||
} from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
|
||||||
import { Slider } from "@/components/ui/slider"
|
|
||||||
import { FeatureImportanceChart } from "./components/feature-importance-chart"
|
|
||||||
import { PriceComparisonChart } from "./components/price-comparison-chart"
|
|
||||||
import { MapSidebar } from "../map/components/map-sidebar"
|
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
|
||||||
|
|
||||||
export default function ModelExplanationPage() {
|
|
||||||
const [activeStep, setActiveStep] = useState(1)
|
|
||||||
const [propertySize, setPropertySize] = useState(150)
|
|
||||||
const [propertyAge, setPropertyAge] = useState(5)
|
|
||||||
|
|
||||||
// Sample data for the model explanation
|
|
||||||
const propertyDetails = {
|
|
||||||
address: "123 Sukhumvit Road, Bangkok",
|
|
||||||
type: "Condominium",
|
|
||||||
size: 150, // sqm
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 2,
|
|
||||||
age: 5, // years
|
|
||||||
floor: 15,
|
|
||||||
amenities: ["Swimming Pool", "Gym", "Security", "Parking"],
|
|
||||||
predictedPrice: 15000000, // THB
|
|
||||||
similarProperties: [
|
|
||||||
{ address: "125 Sukhumvit Road", price: 14500000, size: 145, age: 6 },
|
|
||||||
{ address: "130 Sukhumvit Road", price: 16200000, size: 160, age: 3 },
|
|
||||||
{ address: "118 Sukhumvit Road", price: 13800000, size: 140, age: 7 },
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
{ name: "Location", importance: 35, value: "Prime Area", impact: "positive" },
|
|
||||||
{ name: "Size", importance: 25, value: "150 sqm", impact: "positive" },
|
|
||||||
{ name: "Age", importance: 15, value: "5 years", impact: "neutral" },
|
|
||||||
{ name: "Amenities", importance: 10, value: "4 amenities", impact: "positive" },
|
|
||||||
{ name: "Floor", importance: 8, value: "15th floor", impact: "positive" },
|
|
||||||
{ name: "Environmental Factors", importance: 7, value: "Low flood risk", impact: "positive" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Property Details",
|
|
||||||
description: "Basic information about the property",
|
|
||||||
icon: Home,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Feature Analysis",
|
|
||||||
description: "How each feature affects the price",
|
|
||||||
icon: Ruler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Market Comparison",
|
|
||||||
description: "Comparison with similar properties",
|
|
||||||
icon: Building,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Environmental Factors",
|
|
||||||
description: "Impact of environmental conditions",
|
|
||||||
icon: Droplets,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Final Prediction",
|
|
||||||
description: "The predicted price and confidence level",
|
|
||||||
icon: Coins,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Calculate a new price based on slider changes
|
|
||||||
const calculateAdjustedPrice = () => {
|
|
||||||
// Simple formula for demonstration
|
|
||||||
const sizeImpact = (propertySize - 150) * 50000 // 50,000 THB per sqm
|
|
||||||
const ageImpact = (5 - propertyAge) * 200000 // 200,000 THB per year newer
|
|
||||||
|
|
||||||
return propertyDetails.predictedPrice + sizeImpact + ageImpact
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjustedPrice = calculateAdjustedPrice()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<SidebarProvider>
|
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
|
||||||
<MapSidebar />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="flex h-14 items-center justify-between border-b px-4 bg-background">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Link href="/map" className="hover:text-foreground">
|
|
||||||
Map
|
|
||||||
</Link>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
<span className="font-medium text-foreground">Price Prediction Model</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex flex-1 overflow-auto p-6">
|
|
||||||
<div className="mx-auto w-full max-w-7xl">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Explainable Price Prediction Model</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">
|
|
||||||
Understand how our AI model predicts property prices and what factors influence the valuation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Steps navigation */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{steps.map((step) => (
|
|
||||||
<Button
|
|
||||||
key={step.id}
|
|
||||||
variant={activeStep === step.id ? "default" : "outline"}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => setActiveStep(step.id)}
|
|
||||||
>
|
|
||||||
<step.icon className="h-4 w-4" />
|
|
||||||
<span>{step.title}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Progress value={(activeStep / steps.length) * 100} className="h-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
{/* Left column - Property details */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Property Details</CardTitle>
|
|
||||||
<CardDescription>{propertyDetails.address}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Type</span>
|
|
||||||
<span className="font-medium">{propertyDetails.type}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Size</span>
|
|
||||||
<span className="font-medium">{propertySize} sqm</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Bedrooms</span>
|
|
||||||
<span className="font-medium">{propertyDetails.bedrooms}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Bathrooms</span>
|
|
||||||
<span className="font-medium">{propertyDetails.bathrooms}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Age</span>
|
|
||||||
<span className="font-medium">{propertyAge} years</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Floor</span>
|
|
||||||
<span className="font-medium">{propertyDetails.floor}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Adjust Parameters</CardTitle>
|
|
||||||
<CardDescription>See how changes affect the prediction</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<label className="text-sm font-medium">Property Size</label>
|
|
||||||
<span className="text-sm text-muted-foreground">{propertySize} sqm</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[propertySize]}
|
|
||||||
min={50}
|
|
||||||
max={300}
|
|
||||||
step={5}
|
|
||||||
onValueChange={(value) => setPropertySize(value[0])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<label className="text-sm font-medium">Property Age</label>
|
|
||||||
<span className="text-sm text-muted-foreground">{propertyAge} years</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[propertyAge]}
|
|
||||||
min={0}
|
|
||||||
max={20}
|
|
||||||
step={1}
|
|
||||||
onValueChange={(value) => setPropertyAge(value[0])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex justify-between items-baseline">
|
|
||||||
<span className="text-sm text-muted-foreground">Adjusted Price</span>
|
|
||||||
<span className="text-xl font-bold">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(adjustedPrice)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{adjustedPrice > propertyDetails.predictedPrice ? "↑" : "↓"}
|
|
||||||
{Math.abs(adjustedPrice - propertyDetails.predictedPrice).toLocaleString()} THB from
|
|
||||||
original prediction
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Middle column - Step content */}
|
|
||||||
<div className="md:col-span-2 space-y-6">
|
|
||||||
{activeStep === 1 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Property Overview</CardTitle>
|
|
||||||
<CardDescription>Basic information used in our prediction model</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>
|
|
||||||
Our AI model begins by analyzing the core attributes of your property. These fundamental
|
|
||||||
characteristics form the baseline for our prediction.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border p-3">
|
|
||||||
<Home className="mt-0.5 h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Property Type</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{propertyDetails.type} properties in this area have specific market dynamics
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border p-3">
|
|
||||||
<Ruler className="mt-0.5 h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Size & Layout</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{propertyDetails.size} sqm with {propertyDetails.bedrooms} bedrooms and{" "}
|
|
||||||
{propertyDetails.bathrooms} bathrooms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border p-3">
|
|
||||||
<Calendar className="mt-0.5 h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Property Age</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Built {propertyDetails.age} years ago, affecting depreciation calculations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border p-3">
|
|
||||||
<Building className="mt-0.5 h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Floor & View</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Located on floor {propertyDetails.floor}, impacting value and desirability
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button onClick={() => setActiveStep(2)} className="ml-auto">
|
|
||||||
Next Step <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === 2 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Feature Analysis</CardTitle>
|
|
||||||
<CardDescription>How different features impact the predicted price</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p>
|
|
||||||
Our model analyzes various features of your property and determines how each one
|
|
||||||
contributes to the final price prediction. Below is a breakdown of the most important
|
|
||||||
factors.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<FeatureImportanceChart features={propertyDetails.features} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{propertyDetails.features.map((feature) => (
|
|
||||||
<div key={feature.name} className="space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm font-medium">{feature.name}</span>
|
|
||||||
<span
|
|
||||||
className={`text-sm ${
|
|
||||||
feature.impact === "positive"
|
|
||||||
? "text-green-500"
|
|
||||||
: feature.impact === "negative"
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-yellow-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{feature.impact === "positive"
|
|
||||||
? "↑ Positive"
|
|
||||||
: feature.impact === "negative"
|
|
||||||
? "↓ Negative"
|
|
||||||
: "→ Neutral"}{" "}
|
|
||||||
Impact
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Progress value={feature.importance} className="h-2" />
|
|
||||||
<span className="text-sm text-muted-foreground">{feature.importance}%</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{feature.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setActiveStep(1)}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setActiveStep(3)}>
|
|
||||||
Next Step <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === 3 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Market Comparison</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
How your property compares to similar properties in the area
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p>
|
|
||||||
Our model analyzes recent sales data from similar properties in your area to establish a
|
|
||||||
baseline for comparison. This helps ensure our prediction is aligned with current market
|
|
||||||
conditions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<PriceComparisonChart
|
|
||||||
property={{
|
|
||||||
name: "Your Property",
|
|
||||||
price: propertyDetails.predictedPrice,
|
|
||||||
size: propertyDetails.size,
|
|
||||||
age: propertyDetails.age,
|
|
||||||
}}
|
|
||||||
comparisons={propertyDetails.similarProperties.map((p) => ({
|
|
||||||
name: p.address.split(" ")[0],
|
|
||||||
price: p.price,
|
|
||||||
size: p.size,
|
|
||||||
age: p.age,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="font-medium">Similar Properties</h4>
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{propertyDetails.similarProperties.map((property, index) => (
|
|
||||||
<div key={index} className="rounded-lg border p-3">
|
|
||||||
<div className="font-medium">{property.address}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{property.size} sqm, {property.age} years old
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 font-bold">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(property.price)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setActiveStep(2)}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setActiveStep(4)}>
|
|
||||||
Next Step <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === 4 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Environmental Factors</CardTitle>
|
|
||||||
<CardDescription>How environmental conditions affect the property value</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p>
|
|
||||||
Environmental factors can significantly impact property values. Our model considers
|
|
||||||
various environmental conditions to provide a more accurate prediction.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="flex flex-col items-center rounded-lg border p-4">
|
|
||||||
<Droplets className="h-8 w-8 text-blue-500 mb-2" />
|
|
||||||
<h4 className="font-medium">Flood Risk</h4>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
|
||||||
<span>Moderate</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
|
||||||
Historical data shows moderate flood risk in this area
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center rounded-lg border p-4">
|
|
||||||
<Wind className="h-8 w-8 text-purple-500 mb-2" />
|
|
||||||
<h4 className="font-medium">Air Quality</h4>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
|
||||||
<span>Poor</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
|
||||||
Air quality is below average, affecting property value
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center rounded-lg border p-4">
|
|
||||||
<Sun className="h-8 w-8 text-amber-500 mb-2" />
|
|
||||||
<h4 className="font-medium">Noise Level</h4>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-green-500"></div>
|
|
||||||
<span>Low</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
|
||||||
The area has relatively low noise pollution
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium">Proximity to Amenities</h4>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-2">
|
|
||||||
<Car className="h-4 w-4 text-primary" />
|
|
||||||
<div className="text-sm">Public Transport: 300m</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-2">
|
|
||||||
<School className="h-4 w-4 text-primary" />
|
|
||||||
<div className="text-sm">Schools: 1.2km</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-2">
|
|
||||||
<ShoppingBag className="h-4 w-4 text-primary" />
|
|
||||||
<div className="text-sm">Shopping: 500m</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-2">
|
|
||||||
<Building className="h-4 w-4 text-primary" />
|
|
||||||
<div className="text-sm">Hospitals: 2.5km</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setActiveStep(3)}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setActiveStep(5)}>
|
|
||||||
Next Step <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === 5 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Final Prediction</CardTitle>
|
|
||||||
<CardDescription>The predicted price and confidence level</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-lg bg-muted p-6 text-center">
|
|
||||||
<h3 className="text-lg font-medium text-muted-foreground">Predicted Price</h3>
|
|
||||||
<div className="mt-2 text-4xl font-bold">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(adjustedPrice)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">Confidence Level: 92%</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h4 className="font-medium flex items-center gap-2">
|
|
||||||
<Info className="h-4 w-4 text-primary" />
|
|
||||||
Price Range
|
|
||||||
</h4>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Based on our model's confidence level, the price could range between:
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex justify-between text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Lower Bound</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(adjustedPrice * 0.95)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">Prediction</div>
|
|
||||||
<div className="text-primary font-bold">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(adjustedPrice)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-medium">Upper Bound</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(adjustedPrice * 1.05)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium">Summary of Factors</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The final prediction is based on a combination of all factors analyzed in previous
|
|
||||||
steps:
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 space-y-1 text-sm">
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
|
||||||
<span>Property characteristics (size, age, layout)</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
|
||||||
<span>Location and neighborhood analysis</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
|
||||||
<span>Market trends and comparable properties</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
|
||||||
<span>Environmental factors and amenities</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={() => setActiveStep(4)}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" onClick={() => (window.location.href = "/map")}>
|
|
||||||
Back to Map
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,102 +1,35 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/app/page.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link"; // Import Link
|
||||||
|
import { Button } from "@/components/ui/button"; // Import common UI
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<div className="flex flex-col min-h-screen items-center justify-center p-8 sm:p-20 text-center">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
{/* Replace with your actual logo/branding */}
|
||||||
<Image
|
<h1 className="text-4xl font-bold mb-4">Welcome to Borbann</h1>
|
||||||
className="dark:invert"
|
<p className="text-lg text-muted-foreground mb-8">Your data integration and analysis platform.</p>
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||||||
<a
|
<Link href="/map">
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
<Button size="lg">Go to Map</Button>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</Link>
|
||||||
target="_blank"
|
<Link href="/documentation">
|
||||||
rel="noopener noreferrer"
|
{" "}
|
||||||
>
|
{/* Example link */}
|
||||||
<Image
|
<Button size="lg" variant="outline">
|
||||||
className="dark:invert"
|
Read Docs
|
||||||
src="/vercel.svg"
|
</Button>
|
||||||
alt="Vercel logomark"
|
</Link>
|
||||||
width={20}
|
</div>
|
||||||
height={20}
|
|
||||||
/>
|
{/* Optional: Add more introductory content or links */}
|
||||||
Deploy now
|
<footer className="absolute bottom-8 text-sm text-muted-foreground">
|
||||||
</a>
|
© {new Date().getFullYear()} Borbann Project.
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "tailwind.config.ts",
|
||||||
"css": "app/globals.css",
|
"css": "app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
@ -15,7 +15,10 @@
|
|||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks",
|
||||||
|
"features": "@/features",
|
||||||
|
"types": "@/types",
|
||||||
|
"services": "@/services"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
29
frontend/components/common/PageLayout.tsx
Normal file
29
frontend/components/common/PageLayout.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/components/common/PageLayout.tsx (NEW - Example)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
// Example: Assuming you might have a common Header/Footer/Sidebar structure
|
||||||
|
// import { AppHeader } from './AppHeader';
|
||||||
|
// import { AppFooter } from './AppFooter';
|
||||||
|
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY: A basic layout component for pages.
|
||||||
|
* Might include common headers, footers, or side navigation structures.
|
||||||
|
*/
|
||||||
|
export function PageLayout({ children, className }: PageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col min-h-screen", className)}>
|
||||||
|
{/* <AppHeader /> */} {/* Example: Shared Header */}
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
{/* <AppFooter /> */} {/* Example: Shared Footer */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,9 +1,14 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/components/common/ThemeController.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, type ReactNode } from "react"
|
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react"
|
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -11,70 +16,65 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
// Define available color schemes
|
// Define available color schemes (these affect CSS variables)
|
||||||
const colorSchemes = [
|
const colorSchemes = [
|
||||||
{ name: "Blue", primary: "221.2 83.2% 53.3%" },
|
{ name: "Blue", primary: "221.2 83.2% 53.3%" }, // Default blue
|
||||||
{ name: "Green", primary: "142.1 76.2% 36.3%" },
|
{ name: "Green", primary: "142.1 76.2% 36.3%" },
|
||||||
{ name: "Purple", primary: "262.1 83.3% 57.8%" },
|
{ name: "Purple", primary: "262.1 83.3% 57.8%" },
|
||||||
{ name: "Orange", primary: "24.6 95% 53.1%" },
|
{ name: "Orange", primary: "24.6 95% 53.1%" },
|
||||||
{ name: "Teal", primary: "173 80.4% 40%" },
|
{ name: "Teal", primary: "173 80.4% 40%" },
|
||||||
]
|
];
|
||||||
|
|
||||||
interface ThemeControllerProps {
|
interface ThemeControllerProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
defaultColorScheme?: string
|
defaultColorScheme?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeController({ children, defaultColorScheme = "Blue" }: ThemeControllerProps) {
|
export function ThemeController({ children, defaultColorScheme = "Blue" }: ThemeControllerProps) {
|
||||||
const { setTheme, theme } = useTheme()
|
const { setTheme, theme } = useTheme();
|
||||||
const [colorScheme, setColorScheme] = useState(defaultColorScheme)
|
const [colorScheme, setColorScheme] = useState(defaultColorScheme);
|
||||||
const [overlayBoundaries, setOverlayBoundaries] = useState({ width: 0, height: 0 })
|
// State for overlay boundaries removed, as overlay context now manages positioning/constraints.
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
// const [overlayBoundaries, setOverlayBoundaries] = useState({ width: 0, height: 0 });
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Update overlay boundaries when window resizes
|
// Update overlay boundaries - This logic might be better placed within the OverlayProvider
|
||||||
|
// or removed if not strictly necessary for the controller's function.
|
||||||
|
// Kept here for now as per original code structure, but consider moving it.
|
||||||
|
// useEffect(() => {
|
||||||
|
// const updateBoundaries = () => {
|
||||||
|
// if (containerRef.current) {
|
||||||
|
// const width = containerRef.current.clientWidth;
|
||||||
|
// const height = containerRef.current.clientHeight;
|
||||||
|
// document.documentElement.style.setProperty("--max-overlay-width", `${width - 32}px`);
|
||||||
|
// document.documentElement.style.setProperty("--max-overlay-height", `${height - 32}px`);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// updateBoundaries();
|
||||||
|
// window.addEventListener("resize", updateBoundaries);
|
||||||
|
// return () => window.removeEventListener("resize", updateBoundaries);
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
// Apply color scheme by setting the '--primary' CSS variable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateBoundaries = () => {
|
const scheme = colorSchemes.find((s) => s.name === colorScheme);
|
||||||
if (containerRef.current) {
|
|
||||||
setOverlayBoundaries({
|
|
||||||
width: containerRef.current.clientWidth,
|
|
||||||
height: containerRef.current.clientHeight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial update
|
|
||||||
updateBoundaries()
|
|
||||||
|
|
||||||
// Add resize listener
|
|
||||||
window.addEventListener("resize", updateBoundaries)
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => window.removeEventListener("resize", updateBoundaries)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Apply color scheme
|
|
||||||
useEffect(() => {
|
|
||||||
const scheme = colorSchemes.find((s) => s.name === colorScheme)
|
|
||||||
if (scheme) {
|
if (scheme) {
|
||||||
document.documentElement.style.setProperty("--primary", scheme.primary)
|
document.documentElement.style.setProperty("--primary", scheme.primary);
|
||||||
|
// You might need to set --ring as well if it depends on primary
|
||||||
|
// document.documentElement.style.setProperty("--ring", scheme.primary);
|
||||||
}
|
}
|
||||||
}, [colorScheme])
|
}, [colorScheme]);
|
||||||
|
|
||||||
// Apply CSS variables for overlay constraints
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.setProperty("--max-overlay-width", `${overlayBoundaries.width - 32}px`)
|
|
||||||
document.documentElement.style.setProperty("--max-overlay-height", `${overlayBoundaries.height - 32}px`)
|
|
||||||
}, [overlayBoundaries])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden" data-theme-controller="true">
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden" data-theme-controller="true">
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Theme Controller UI */}
|
{/* Theme Controller UI */}
|
||||||
<div className="fixed bottom-4 left-4 z-50 flex items-center gap-2">
|
<div className="fixed bottom-4 left-4 z-[999] flex items-center gap-2">
|
||||||
|
{" "}
|
||||||
|
{/* Ensure high z-index */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@ -83,8 +83,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md">
|
||||||
>
|
|
||||||
<Palette className="h-5 w-5" />
|
<Palette className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@ -123,8 +122,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={scheme.name}
|
key={scheme.name}
|
||||||
onClick={() => setColorScheme(scheme.name)}
|
onClick={() => setColorScheme(scheme.name)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between">
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-4 w-4 rounded-full" style={{ backgroundColor: `hsl(${scheme.primary})` }} />
|
<div className="h-4 w-4 rounded-full" style={{ backgroundColor: `hsl(${scheme.primary})` }} />
|
||||||
<span>{scheme.name}</span>
|
<span>{scheme.name}</span>
|
||||||
@ -142,6 +140,5 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
23
frontend/components/common/ThemeProvider.tsx
Normal file
23
frontend/components/common/ThemeProvider.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/components/common/ThemeProvider.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import type { ThemeProviderProps } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
{...props} // Pass through any other props
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,24 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/components/common/ThemeToggle.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { Moon, Sun, Laptop } from "lucide-react"
|
import React from "react";
|
||||||
import { useTheme } from "next-themes"
|
import { Moon, Sun, Laptop } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { useTheme } from "next-themes";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setTheme, theme } = useTheme()
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@ -42,6 +53,5 @@ export function ThemeToggle() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
|
||||||
import type { ThemeProviderProps } from "next-themes"
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|
||||||
return (
|
|
||||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange {...props}>
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,16 +1,72 @@
|
|||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
import eslintJs from "@eslint/js"; // Import recommended rules
|
||||||
|
import tseslint from "typescript-eslint"; // Import TS plugin/parser
|
||||||
|
import eslintPluginReact from "eslint-plugin-react"; // Import React plugin
|
||||||
|
import eslintConfigNext from "eslint-config-next"; // Import Next.js specific config
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// FlatCompat for extending older eslintrc-style configs if needed (like next/core-web-vitals)
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: __dirname,
|
baseDirectory: __dirname,
|
||||||
|
// resolvePluginsRelativeTo: __dirname, // might be needed depending on setup
|
||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
// Base ESLint recommended rules
|
||||||
|
eslintJs.configs.recommended,
|
||||||
|
|
||||||
|
// TypeScript specific rules using the new flat config structure
|
||||||
|
...tseslint.configs.recommended, // Or recommendedTypeChecked if using type info
|
||||||
|
|
||||||
|
// React specific rules (new flat config structure)
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
react: eslintPluginReact,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintPluginReact.configs.recommended.rules,
|
||||||
|
...eslintPluginReact.configs["jsx-runtime"].rules, // Recommended for React 17+ JSX transform
|
||||||
|
"react/prop-types": "off", // Not needed with TypeScript
|
||||||
|
// Add other React specific rules here
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect", // Automatically detect React version
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Next.js specific rules using FlatCompat for `eslint-config-next`
|
||||||
|
// This often includes rules for react-hooks, jsx-a11y etc.
|
||||||
|
...compat.extends("next/core-web-vitals"),
|
||||||
|
|
||||||
|
// Custom rules or overrides
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Example: Enforce no console logs in production (modify as needed)
|
||||||
|
// 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], // Warn on unused vars except those starting with _
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn", // Warn against using 'any'
|
||||||
|
// Add more custom rules here
|
||||||
|
},
|
||||||
|
// Target specific files if necessary
|
||||||
|
// files: ["src/**/*.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ignore files if needed (e.g., generated files)
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
".next/",
|
||||||
|
"node_modules/",
|
||||||
|
"out/",
|
||||||
|
"build/",
|
||||||
|
// Add other ignored paths
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
65
frontend/features/map/api/mapApi.ts
Normal file
65
frontend/features/map/api/mapApi.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/api/mapApi.ts
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import apiClient from "@/services/apiClient";
|
||||||
|
import type { APIResponse, PointOfInterest } from "@/types/api"; // Shared types
|
||||||
|
import type { MapBounds } from "../types"; // Feature-specific types
|
||||||
|
|
||||||
|
interface FetchPOIsParams {
|
||||||
|
bounds: MapBounds;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY: Fetches Points of Interest based on map bounds and filters.
|
||||||
|
*/
|
||||||
|
export async function fetchPointsOfInterest(
|
||||||
|
params: FetchPOIsParams
|
||||||
|
): Promise<APIResponse<PointOfInterest[]>> {
|
||||||
|
console.log("DUMMY API: Fetching POIs with params:", params);
|
||||||
|
|
||||||
|
// Simulate building query parameters
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
north: params.bounds.north.toString(),
|
||||||
|
south: params.bounds.south.toString(),
|
||||||
|
east: params.bounds.east.toString(),
|
||||||
|
west: params.bounds.west.toString(),
|
||||||
|
});
|
||||||
|
if (params.filters) {
|
||||||
|
Object.entries(params.filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
queryParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the dummy apiClient
|
||||||
|
const response = await apiClient.get<PointOfInterest[]>(`/map/pois?${queryParams.toString()}`);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Simulate adding more data if needed for testing
|
||||||
|
const dummyData: PointOfInterest[] = [
|
||||||
|
{ id: "poi1", lat: params.bounds.north - 0.01, lng: params.bounds.west + 0.01, name: "Dummy Cafe", type: "cafe" },
|
||||||
|
{ id: "poi2", lat: params.bounds.south + 0.01, lng: params.bounds.east - 0.01, name: "Dummy Park", type: "park" },
|
||||||
|
...(response.data || []) // Include data if apiClient simulation provides it
|
||||||
|
];
|
||||||
|
return { success: true, data: dummyData };
|
||||||
|
} else {
|
||||||
|
return response; // Forward the error response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other map-related API functions here
|
||||||
|
export async function fetchMapAnalytics(bounds: MapBounds): Promise<APIResponse<any>> {
|
||||||
|
console.log("DUMMY API: Fetching Map Analytics with params:", bounds);
|
||||||
|
// Simulate building query parameters
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
north: bounds.north.toString(),
|
||||||
|
south: bounds.south.toString(),
|
||||||
|
east: bounds.east.toString(),
|
||||||
|
west: bounds.west.toString(),
|
||||||
|
});
|
||||||
|
return apiClient.get(`/map/analytics?${queryParams.toString()}`);
|
||||||
|
}
|
||||||
@ -1,17 +1,22 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/analytics-overlay.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { LineChart, Wind, Droplets, Sparkles, Bot } from "lucide-react"
|
import { LineChart, Wind, Droplets, Sparkles, Bot } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AreaChart } from "./area-chart"
|
import { AreaChart } from "./area-chart";
|
||||||
import { Overlay } from "./overlay-system/overlay"
|
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
|
||||||
import { useOverlay } from "./overlay-system/overlay-context"
|
import { useOverlay } from "./overlay-system/overlay-context";
|
||||||
|
|
||||||
export function AnalyticsOverlay() {
|
export function AnalyticsOverlay() {
|
||||||
const { toggleOverlay } = useOverlay()
|
const { toggleOverlay } = useOverlay();
|
||||||
|
|
||||||
const handleChatClick = () => {
|
const handleChatClick = () => {
|
||||||
toggleOverlay("chat")
|
toggleOverlay("chat");
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay
|
||||||
@ -20,13 +25,13 @@ export function AnalyticsOverlay() {
|
|||||||
icon={<Sparkles className="h-5 w-5" />}
|
icon={<Sparkles className="h-5 w-5" />}
|
||||||
initialPosition="top-right"
|
initialPosition="top-right"
|
||||||
initialIsOpen={true}
|
initialIsOpen={true}
|
||||||
width="350px"
|
width="350px">
|
||||||
>
|
|
||||||
<div className="h-[calc(min(70vh,600px))] overflow-auto">
|
<div className="h-[calc(min(70vh,600px))] overflow-auto">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-xs text-muted-foreground">Information in radius will be analyzed</p>
|
<p className="text-xs text-muted-foreground">Information in radius will be analyzed</p>
|
||||||
|
|
||||||
|
{/* Area Price History Card */}
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
@ -46,6 +51,7 @@ export function AnalyticsOverlay() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Price Prediction Card */}
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
@ -65,6 +71,7 @@ export function AnalyticsOverlay() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Environmental Factors Cards */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
@ -93,10 +100,10 @@ export function AnalyticsOverlay() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chat With AI Card */}
|
||||||
<Card
|
<Card
|
||||||
className="bg-card/50 border border-border/50 shadow-sm cursor-pointer hover:bg-muted/50 transition-colors"
|
className="bg-card/50 border border-border/50 shadow-sm cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={handleChatClick}
|
onClick={handleChatClick}>
|
||||||
>
|
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
<Bot className="h-4 w-4 text-teal-500" />
|
<Bot className="h-4 w-4 text-teal-500" />
|
||||||
@ -109,6 +116,5 @@ export function AnalyticsOverlay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
10
frontend/features/map/components/analytics-panel.tsx
Normal file
10
frontend/features/map/components/analytics-panel.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/analytics-panel.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
// This component seems redundant with analytics-overlay.tsx in the new structure.
|
||||||
|
// If it has unique logic or display variation, keep it and refactor its imports.
|
||||||
|
// Otherwise, it can likely be removed, and its functionality merged into analytics-overlay.tsx.
|
||||||
|
// For this rewrite, assuming it's removed or its distinct logic is integrated elsewhere.
|
||||||
|
// If needed, its structure would be similar to analytics-overlay.tsx but maybe without the <Overlay> wrapper.
|
||||||
@ -1,38 +1,45 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/area-chart.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from "@/components/ui/chart"
|
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from "@/components/ui/chart"; // Using shared ui chart components
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
interface AreaChartProps {
|
interface AreaChartProps {
|
||||||
data: number[]
|
data: number[];
|
||||||
color: string
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AreaChart({ data, color }: AreaChartProps) {
|
export function AreaChart({ data, color }: AreaChartProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark"
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
// Generate labels (months)
|
// Generate labels (e.g., months or simple indices)
|
||||||
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
|
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]; // Example labels
|
||||||
|
|
||||||
// Format the data for the chart
|
// Format the data for the chart
|
||||||
const chartData = data.map((value, index) => ({
|
const chartData = data.map((value, index) => ({
|
||||||
name: labels[index],
|
name: labels[index % labels.length] || `Point ${index + 1}`, // Use labels or fallback
|
||||||
value: value,
|
value: value,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Format the price for display
|
// Format the price for display in tooltip
|
||||||
const formatPrice = (value: number) => {
|
const formatPrice = (value: number) => {
|
||||||
return new Intl.NumberFormat("th-TH", {
|
return new Intl.NumberFormat("th-TH", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "THB",
|
currency: "THB",
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value)
|
}).format(value);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[80px] w-full">
|
<div className="h-[80px] w-full">
|
||||||
|
{" "}
|
||||||
|
{/* Adjust height as needed */}
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="name" hide />
|
<XAxis dataKey="name" hide />
|
||||||
@ -50,16 +57,17 @@ export function AreaChart({ data, color }: AreaChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")}
|
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")} // Ensure valid RGB for stroke
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ r: 3, strokeWidth: 1 }}
|
dot={{ r: 3, strokeWidth: 1 }}
|
||||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||||
fill={color}
|
// Area charts typically use <Area>, but keeping <Line> based on original code
|
||||||
fillOpacity={0.5}
|
// If area fill is desired:
|
||||||
|
// fill={color}
|
||||||
|
// fillOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
8
frontend/features/map/components/chat-bot.tsx
Normal file
8
frontend/features/map/components/chat-bot.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/chat-bot.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
// This component seems redundant with chat-overlay.tsx.
|
||||||
|
// Assuming it's removed or its logic is integrated into chat-overlay.tsx.
|
||||||
|
// If needed, its structure would be similar to chat-overlay.tsx but maybe without the <Overlay> wrapper.
|
||||||
@ -1,50 +1,50 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/filters-overlay.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Filter } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider"
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label";
|
||||||
import { Minimize2, Maximize2 } from "lucide-react"
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useOverlayContext } from "./overlay-context"
|
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
|
|
||||||
export function PropertyFilters() {
|
export function FiltersOverlay() {
|
||||||
const { overlays, minimizeOverlay, maximizeOverlay } = useOverlayContext()
|
const [area, setArea] = useState("< 30 km");
|
||||||
const isMinimized = overlays.filters.minimized
|
const [timePeriod, setTimePeriod] = useState("All Time");
|
||||||
|
const [propertyType, setPropertyType] = useState("House");
|
||||||
|
const [priceRange, setPriceRange] = useState([5_000_000, 20_000_000]);
|
||||||
|
const [activeTab, setActiveTab] = useState("basic");
|
||||||
|
|
||||||
const [area, setArea] = useState("< 30 km")
|
const handleApplyFilters = () => {
|
||||||
const [timePeriod, setTimePeriod] = useState("All Time")
|
console.log("DUMMY: Applying filters:", {
|
||||||
const [propertyType, setPropertyType] = useState("House")
|
area,
|
||||||
const [priceRange, setPriceRange] = useState([5000000, 20000000])
|
timePeriod,
|
||||||
const [activeTab, setActiveTab] = useState("basic")
|
propertyType,
|
||||||
|
priceRange, // Include advanced filters state here
|
||||||
if (isMinimized) {
|
});
|
||||||
return (
|
// In real app: trigger data refetch with these filters
|
||||||
<Card className="overlay-card overlay-minimized">
|
};
|
||||||
<CardHeader className="p-2 flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm font-medium">Filters</CardTitle>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => maximizeOverlay("filters")}>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overlay-card w-[350px] max-h-[80vh] overflow-hidden">
|
<Overlay
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
id="filters"
|
||||||
<CardTitle className="text-sm font-medium">Property Filters</CardTitle>
|
title="Property Filters"
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("filters")}>
|
icon={<Filter className="h-5 w-5" />}
|
||||||
<Minimize2 className="h-4 w-4" />
|
initialPosition="bottom-left"
|
||||||
</Button>
|
initialIsOpen={true}
|
||||||
</CardHeader>
|
width="350px">
|
||||||
<ScrollArea className="h-full max-h-[calc(min(80vh,var(--max-overlay-height))-60px)]">
|
<ScrollArea className="h-[calc(min(70vh,500px))]">
|
||||||
<CardContent className="p-4">
|
{" "}
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="p-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||||
@ -54,9 +54,11 @@ export function PropertyFilters() {
|
|||||||
<TabsContent value="basic" className="space-y-4">
|
<TabsContent value="basic" className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium">Area Radius</label>
|
<Label htmlFor="area-radius" className="text-xs font-medium">
|
||||||
|
Area Radius
|
||||||
|
</Label>
|
||||||
<Select value={area} onValueChange={setArea}>
|
<Select value={area} onValueChange={setArea}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="area-radius">
|
||||||
<SelectValue placeholder="Select area" />
|
<SelectValue placeholder="Select area" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -68,9 +70,11 @@ export function PropertyFilters() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium">Time Period</label>
|
<Label htmlFor="time-period" className="text-xs font-medium">
|
||||||
|
Time Period
|
||||||
|
</Label>
|
||||||
<Select value={timePeriod} onValueChange={setTimePeriod}>
|
<Select value={timePeriod} onValueChange={setTimePeriod}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="time-period">
|
||||||
<SelectValue placeholder="Select time period" />
|
<SelectValue placeholder="Select time period" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -83,9 +87,11 @@ export function PropertyFilters() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium">Property Type</label>
|
<Label htmlFor="property-type" className="text-xs font-medium">
|
||||||
|
Property Type
|
||||||
|
</Label>
|
||||||
<Select value={propertyType} onValueChange={setPropertyType}>
|
<Select value={propertyType} onValueChange={setPropertyType}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="property-type">
|
||||||
<SelectValue placeholder="Select property type" />
|
<SelectValue placeholder="Select property type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -103,34 +109,26 @@ export function PropertyFilters() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between mb-1">
|
<div className="flex justify-between mb-1">
|
||||||
<label className="text-xs font-medium">Price Range</label>
|
<Label htmlFor="price-range" className="text-xs font-medium">
|
||||||
|
Price Range
|
||||||
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Intl.NumberFormat("th-TH", {
|
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[0])} -{" "}
|
||||||
style: "currency",
|
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[1])} ฿
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(priceRange[0])}{" "}
|
|
||||||
-{" "}
|
|
||||||
{new Intl.NumberFormat("th-TH", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "THB",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(priceRange[1])}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
|
id="price-range"
|
||||||
value={priceRange}
|
value={priceRange}
|
||||||
min={1000000}
|
min={1_000_000}
|
||||||
max={50000000}
|
max={50_000_000}
|
||||||
step={1000000}
|
step={100_000} // Finer step
|
||||||
onValueChange={setPriceRange}
|
onValueChange={setPriceRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium">Environmental Factors</label>
|
<Label className="text-xs font-medium">Environmental Factors</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="low-flood" className="text-xs">
|
<Label htmlFor="low-flood" className="text-xs">
|
||||||
@ -156,12 +154,11 @@ export function PropertyFilters() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Button className="mt-4 w-full" size="sm">
|
<Button className="mt-4 w-full" size="sm" onClick={handleApplyFilters}>
|
||||||
Apply Filters
|
Apply Filters
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Overlay>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
75
frontend/features/map/components/map-container.tsx
Normal file
75
frontend/features/map/components/map-container.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/map-container.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import type { MapLocation } from "../types"; // Feature-specific type
|
||||||
|
|
||||||
|
interface MapContainerProps {
|
||||||
|
selectedLocation: MapLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapContainer({ selectedLocation }: MapContainerProps) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mapElement = mapRef.current;
|
||||||
|
console.log("DUMMY MAP: Initializing/updating for:", selectedLocation);
|
||||||
|
|
||||||
|
if (mapElement) {
|
||||||
|
// Placeholder for actual map library integration (e.g., Leaflet, Mapbox GL JS, Google Maps API)
|
||||||
|
mapElement.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background-image: url('/placeholder.svg?height=800&width=1200'); /* Using placeholder */
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: sans-serif; color: #555;
|
||||||
|
border: 1px dashed #aaa;
|
||||||
|
position: relative; /* Needed for marker positioning */
|
||||||
|
">
|
||||||
|
Map Placeholder: Centered on ${selectedLocation.name || "location"} (${selectedLocation.lat.toFixed(
|
||||||
|
4
|
||||||
|
)}, ${selectedLocation.lng.toFixed(4)})
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: red;
|
||||||
|
border: 4px solid rgba(255, 100, 100, 0.5);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(255, 0, 0, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
// In a real app, you'd initialize the map library here, set view, add layers/markers.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
console.log("DUMMY MAP: Cleaning up map instance");
|
||||||
|
if (mapElement) {
|
||||||
|
mapElement.innerHTML = ""; // Clear placeholder
|
||||||
|
// In a real app, you'd properly destroy the map instance here.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [selectedLocation]); // Re-run effect if location changes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={mapRef} className="absolute inset-0 h-full w-full bg-muted/20 dark:bg-muted/10">
|
||||||
|
{/* The map library will render into this div */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,32 +1,40 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/map-header.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react";
|
||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/common/ThemeToggle"; // Import from common
|
||||||
|
|
||||||
export function MapHeader() {
|
export function MapHeader() {
|
||||||
|
// Add any map-specific header logic here if needed
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between border-b px-4 bg-background">
|
<header className="flex h-14 items-center justify-between border-b px-4 bg-background flex-shrink-0">
|
||||||
|
{/* Breadcrumbs or Title */}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Link href="/tools" className="hover:text-foreground">
|
<Link href="/tools" className="hover:text-foreground">
|
||||||
|
{" "}
|
||||||
|
{/* Example link */}
|
||||||
Tools
|
Tools
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span className="font-medium text-foreground">Map</span>
|
<span className="font-medium text-foreground">Map</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Header Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<Button variant="outline" size="sm" className="ml-2">
|
<Button variant="outline" size="sm" className="ml-2">
|
||||||
Buy & Sell
|
Dummy Action 1
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
Send & Receive
|
Dummy Action 2
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
144
frontend/features/map/components/map-sidebar.tsx
Normal file
144
frontend/features/map/components/map-sidebar.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/map-sidebar.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Map, // Changed from Clock
|
||||||
|
BarChart3, // Changed from Map
|
||||||
|
Layers, // Changed from FileText
|
||||||
|
Settings,
|
||||||
|
SlidersHorizontal, // Changed from PenTool
|
||||||
|
MessageCircle, // Changed from BarChart3
|
||||||
|
Info, // Changed from Plane
|
||||||
|
LineChart,
|
||||||
|
DollarSign,
|
||||||
|
MoreHorizontal,
|
||||||
|
Gift, // Added Gift icon component below
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
} from "@/components/ui/sidebar"; // Assuming sidebar is a shared UI component structure
|
||||||
|
|
||||||
|
export function MapSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Define navigation items relevant to the map context or general app navigation shown here
|
||||||
|
const mainNavItems = [
|
||||||
|
{ name: "Map View", icon: Map, href: "/map" },
|
||||||
|
{ name: "Analytics", icon: BarChart3, href: "/map/analytics" }, // Example sub-route
|
||||||
|
{ name: "Filters", icon: SlidersHorizontal, href: "/map/filters" }, // Example sub-route
|
||||||
|
{ name: "Data Layers", icon: Layers, href: "/map/layers" }, // Example sub-route
|
||||||
|
{ name: "Chat", icon: MessageCircle, href: "/map/chat" }, // Example sub-route
|
||||||
|
{ name: "Model Info", icon: Info, href: "/model-explanation" }, // Link to other feature
|
||||||
|
{ name: "Settings", icon: Settings, href: "/settings" }, // Example general setting
|
||||||
|
{ name: "More", icon: MoreHorizontal, href: "/more" }, // Example general setting
|
||||||
|
];
|
||||||
|
|
||||||
|
// Example project-specific items (if sidebar is shared)
|
||||||
|
const projectNavItems = [
|
||||||
|
{ name: "Market Trends", icon: LineChart, href: "/projects/trends" },
|
||||||
|
{ name: "Investment", icon: DollarSign, href: "/projects/investment" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Using the shared Sidebar component structure
|
||||||
|
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
||||||
|
<SidebarHeader>
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-semibold px-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
B
|
||||||
|
</div>
|
||||||
|
{/* Hide text when collapsed */}
|
||||||
|
<span className="text-xl font-bold group-data-[collapsed]:hidden">BorBann</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent className="p-2">
|
||||||
|
<SidebarMenu>
|
||||||
|
{mainNavItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.name}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild // Use asChild if the button itself is a Link or wraps one
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
isActive={pathname === item.href}
|
||||||
|
tooltip={item.name} // Tooltip shown when collapsed
|
||||||
|
>
|
||||||
|
<Link href={item.href}>
|
||||||
|
<item.icon />
|
||||||
|
{/* Hide text when collapsed */}
|
||||||
|
<span className="group-data-[collapsed]:hidden">{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
|
||||||
|
{/* Optional Project Section */}
|
||||||
|
{/* <SidebarSeparator />
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{projectNavItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.name}>
|
||||||
|
<SidebarMenuButton >...</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup> */}
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
{/* Footer content like user profile, settings shortcut etc. */}
|
||||||
|
<div className="flex items-center gap-3 p-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground text-xs">
|
||||||
|
GG
|
||||||
|
</div>
|
||||||
|
<div className="group-data-[collapsed]:hidden">
|
||||||
|
<div className="font-medium text-sm">GG_WPX</div>
|
||||||
|
<div className="text-xs text-muted-foreground">gg@example.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example Gift Icon (if not using lucide-react)
|
||||||
|
function GiftIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
{...props}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round">
|
||||||
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/overlay-system/overlay-context.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react";
|
import React, { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react";
|
||||||
@ -20,7 +25,7 @@ export interface OverlayState {
|
|||||||
// Interface for the overlay context
|
// Interface for the overlay context
|
||||||
interface OverlayContextType {
|
interface OverlayContextType {
|
||||||
overlays: Record<OverlayId, OverlayState>;
|
overlays: Record<OverlayId, OverlayState>;
|
||||||
registerOverlay: (id: OverlayId, initialState: Partial<OverlayState>) => void;
|
registerOverlay: (id: OverlayId, initialState: Partial<Omit<OverlayState, "id" | "zIndex">>) => void;
|
||||||
unregisterOverlay: (id: OverlayId) => void;
|
unregisterOverlay: (id: OverlayId) => void;
|
||||||
openOverlay: (id: OverlayId) => void;
|
openOverlay: (id: OverlayId) => void;
|
||||||
closeOverlay: (id: OverlayId) => void;
|
closeOverlay: (id: OverlayId) => void;
|
||||||
@ -36,45 +41,52 @@ interface OverlayContextType {
|
|||||||
const OverlayContext = createContext<OverlayContextType | undefined>(undefined);
|
const OverlayContext = createContext<OverlayContextType | undefined>(undefined);
|
||||||
|
|
||||||
// Default values for overlay state
|
// Default values for overlay state
|
||||||
const defaultOverlayState: Omit<OverlayState, "id" | "title"> = {
|
const defaultOverlayState: Omit<OverlayState, "id" | "title" | "icon"> = {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
position: "bottom-right",
|
position: "bottom-right", // Default position
|
||||||
zIndex: 10,
|
zIndex: 10, // Starting z-index
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OverlayProvider({ children }: { children: ReactNode }) {
|
export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||||
const [overlays, setOverlays] = useState<Record<OverlayId, OverlayState>>({});
|
const [overlays, setOverlays] = useState<Record<OverlayId, OverlayState>>({});
|
||||||
const maxZIndexRef = useRef(10);
|
const maxZIndexRef = useRef(10); // Start z-index from 10
|
||||||
|
|
||||||
// Get the next z-index value using a ref so it doesn't trigger re-renders
|
// Get the next z-index value
|
||||||
const getNextZIndex = useCallback(() => {
|
const getNextZIndex = useCallback(() => {
|
||||||
maxZIndexRef.current++;
|
maxZIndexRef.current += 1;
|
||||||
return maxZIndexRef.current;
|
return maxZIndexRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Register a new overlay
|
// Register a new overlay
|
||||||
const registerOverlay = useCallback((id: OverlayId, initialState: Partial<OverlayState>) => {
|
const registerOverlay = useCallback(
|
||||||
setOverlays((prev) => {
|
(id: OverlayId, initialState: Partial<Omit<OverlayState, "id" | "zIndex">>) => {
|
||||||
if (prev[id]) return prev;
|
setOverlays((prev) => {
|
||||||
return {
|
if (prev[id]) {
|
||||||
...prev,
|
console.warn(`Overlay with id "${id}" already registered.`);
|
||||||
[id]: {
|
return prev;
|
||||||
...defaultOverlayState,
|
}
|
||||||
id,
|
const newZIndex = initialState.isOpen ? getNextZIndex() : defaultOverlayState.zIndex;
|
||||||
title: id,
|
return {
|
||||||
...initialState,
|
...prev,
|
||||||
},
|
[id]: {
|
||||||
};
|
...defaultOverlayState,
|
||||||
});
|
id,
|
||||||
}, []);
|
title: id, // Default title to id
|
||||||
|
...initialState,
|
||||||
|
zIndex: newZIndex, // Set initial z-index
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getNextZIndex]
|
||||||
|
);
|
||||||
|
|
||||||
// Unregister an overlay
|
// Unregister an overlay
|
||||||
const unregisterOverlay = useCallback((id: OverlayId) => {
|
const unregisterOverlay = useCallback((id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
const newOverlays = { ...prev };
|
const { [id]: _, ...rest } = prev; // Use destructuring to remove the key
|
||||||
delete newOverlays[id];
|
return rest;
|
||||||
return newOverlays;
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -82,14 +94,14 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
const openOverlay = useCallback(
|
const openOverlay = useCallback(
|
||||||
(id: OverlayId) => {
|
(id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id] || prev[id].isOpen) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: {
|
||||||
...prev[id],
|
...prev[id],
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
isMinimized: false,
|
isMinimized: false, // Ensure not minimized when opened
|
||||||
zIndex: getNextZIndex(),
|
zIndex: getNextZIndex(), // Bring to front
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -100,33 +112,31 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
// Close an overlay
|
// Close an overlay
|
||||||
const closeOverlay = useCallback((id: OverlayId) => {
|
const closeOverlay = useCallback((id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id] || !prev[id].isOpen) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: { ...prev[id], isOpen: false },
|
||||||
...prev[id],
|
|
||||||
isOpen: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Toggle an overlay
|
// Toggle an overlay's open/closed state
|
||||||
const toggleOverlay = useCallback(
|
const toggleOverlay = useCallback(
|
||||||
(id: OverlayId) => {
|
(id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id]) return prev; // Don't toggle non-existent overlays
|
||||||
const newState = {
|
|
||||||
...prev[id],
|
const willBeOpen = !prev[id].isOpen;
|
||||||
isOpen: !prev[id].isOpen,
|
const newZIndex = willBeOpen ? getNextZIndex() : prev[id].zIndex; // Bring to front only if opening
|
||||||
};
|
|
||||||
if (newState.isOpen) {
|
|
||||||
newState.isMinimized = false;
|
|
||||||
newState.zIndex = getNextZIndex();
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: newState,
|
[id]: {
|
||||||
|
...prev[id],
|
||||||
|
isOpen: willBeOpen,
|
||||||
|
isMinimized: willBeOpen ? false : prev[id].isMinimized, // Maximize when toggling open
|
||||||
|
zIndex: newZIndex,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -136,12 +146,13 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
// Minimize an overlay
|
// Minimize an overlay
|
||||||
const minimizeOverlay = useCallback((id: OverlayId) => {
|
const minimizeOverlay = useCallback((id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id] || !prev[id].isOpen || prev[id].isMinimized) return prev; // Only minimize open, non-minimized overlays
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: {
|
||||||
...prev[id],
|
...prev[id],
|
||||||
isMinimized: true,
|
isMinimized: true,
|
||||||
|
// Optionally send to back when minimized: zIndex: defaultOverlayState.zIndex
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -151,13 +162,13 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
const maximizeOverlay = useCallback(
|
const maximizeOverlay = useCallback(
|
||||||
(id: OverlayId) => {
|
(id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id] || !prev[id].isOpen || !prev[id].isMinimized) return prev; // Only maximize minimized overlays
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: {
|
||||||
...prev[id],
|
...prev[id],
|
||||||
isMinimized: false,
|
isMinimized: false,
|
||||||
zIndex: getNextZIndex(),
|
zIndex: getNextZIndex(), // Bring to front when maximized
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -171,10 +182,7 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!prev[id]) return prev;
|
if (!prev[id]) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: { ...prev[id], position },
|
||||||
...prev[id],
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@ -183,13 +191,12 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
const bringToFront = useCallback(
|
const bringToFront = useCallback(
|
||||||
(id: OverlayId) => {
|
(id: OverlayId) => {
|
||||||
setOverlays((prev) => {
|
setOverlays((prev) => {
|
||||||
if (!prev[id]) return prev;
|
if (!prev[id] || !prev[id].isOpen) return prev; // Only bring open overlays to front
|
||||||
|
// Avoid getting new zIndex if already on top
|
||||||
|
if (prev[id].zIndex === maxZIndexRef.current) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[id]: {
|
[id]: { ...prev[id], zIndex: getNextZIndex() },
|
||||||
...prev[id],
|
|
||||||
zIndex: getNextZIndex(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -213,6 +220,7 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|||||||
return <OverlayContext.Provider value={value}>{children}</OverlayContext.Provider>;
|
return <OverlayContext.Provider value={value}>{children}</OverlayContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook to use the overlay context
|
||||||
export function useOverlay() {
|
export function useOverlay() {
|
||||||
const context = useContext(OverlayContext);
|
const context = useContext(OverlayContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
@ -220,4 +228,3 @@ export function useOverlay() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/overlay-system/overlay-dock.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useOverlay } from "./overlay-context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OverlayDockProps {
|
||||||
|
position?: "bottom" | "right" | "left" | "top"; // Added more positions
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverlayDock({ position = "bottom", className }: OverlayDockProps) {
|
||||||
|
const { overlays, toggleOverlay } = useOverlay();
|
||||||
|
|
||||||
|
// Filter overlays that have icons defined (and potentially are registered)
|
||||||
|
const dockableOverlays = Object.values(overlays).filter((overlay) => overlay.icon);
|
||||||
|
|
||||||
|
// No need to render if there are no dockable overlays
|
||||||
|
if (dockableOverlays.length === 0) return null;
|
||||||
|
|
||||||
|
// Define CSS classes for different positions
|
||||||
|
const positionClasses = {
|
||||||
|
bottom: "fixed bottom-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
|
||||||
|
right: "fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
|
||||||
|
left: "fixed left-4 top-1/2 -translate-y-1/2 flex flex-col gap-2 z-50",
|
||||||
|
top: "fixed top-4 left-1/2 -translate-x-1/2 flex flex-row gap-2 z-50",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipSide = {
|
||||||
|
bottom: "top",
|
||||||
|
top: "bottom",
|
||||||
|
left: "right",
|
||||||
|
right: "left",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn(positionClasses[position], className)}>
|
||||||
|
{dockableOverlays.map((overlay) => (
|
||||||
|
<div key={overlay.id}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={overlay.isOpen && !overlay.isMinimized ? "default" : "outline"} // Highlight if open and not minimized
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 rounded-full bg-background/90 backdrop-blur-sm shadow-md"
|
||||||
|
onClick={() => toggleOverlay(overlay.id)}>
|
||||||
|
{overlay.icon}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={tooltipSide[position]}>
|
||||||
|
{overlay.isOpen ? `Hide ${overlay.title}` : `Show ${overlay.title}`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/features/map/components/overlay-system/overlay.tsx
Normal file
219
frontend/features/map/components/overlay-system/overlay.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/overlay-system/overlay.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
import { X, Minimize2, Maximize2, Move } from "lucide-react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useOverlay, type OverlayId, type OverlayPosition } from "./overlay-context";
|
||||||
|
|
||||||
|
interface OverlayProps {
|
||||||
|
id: OverlayId;
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
initialPosition?: OverlayPosition;
|
||||||
|
initialIsOpen?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
showMinimize?: boolean;
|
||||||
|
width?: string;
|
||||||
|
height?: string; // Can be 'auto' or specific value like '400px'
|
||||||
|
maxHeight?: string; // e.g., '80vh'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Overlay({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
initialPosition = "bottom-right",
|
||||||
|
initialIsOpen = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
showMinimize = true,
|
||||||
|
width = "350px",
|
||||||
|
height = "auto",
|
||||||
|
maxHeight = "80vh", // Default max height
|
||||||
|
}: OverlayProps) {
|
||||||
|
const {
|
||||||
|
overlays,
|
||||||
|
registerOverlay,
|
||||||
|
unregisterOverlay,
|
||||||
|
closeOverlay,
|
||||||
|
minimizeOverlay,
|
||||||
|
maximizeOverlay,
|
||||||
|
bringToFront,
|
||||||
|
// Add setPosition if dragging is implemented
|
||||||
|
} = useOverlay();
|
||||||
|
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
// State for dragging logic (Optional, basic example commented out)
|
||||||
|
// const [isDragging, setIsDragging] = useState(false);
|
||||||
|
// const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Register overlay on mount
|
||||||
|
useEffect(() => {
|
||||||
|
registerOverlay(id, {
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
position: initialPosition,
|
||||||
|
isOpen: initialIsOpen,
|
||||||
|
// Add initial zIndex if needed, otherwise context handles it
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister on unmount
|
||||||
|
return () => unregisterOverlay(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, registerOverlay, unregisterOverlay]); // Only run once on mount/unmount
|
||||||
|
|
||||||
|
// Get the current state of this overlay
|
||||||
|
const overlay = overlays[id];
|
||||||
|
|
||||||
|
// --- Optional Dragging Logic ---
|
||||||
|
// const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// if (!overlayRef.current) return;
|
||||||
|
// bringToFront(id);
|
||||||
|
// setIsDragging(true);
|
||||||
|
// const rect = overlayRef.current.getBoundingClientRect();
|
||||||
|
// setOffset({
|
||||||
|
// x: e.clientX - rect.left,
|
||||||
|
// y: e.clientY - rect.top,
|
||||||
|
// });
|
||||||
|
// // Prevent text selection during drag
|
||||||
|
// e.preventDefault();
|
||||||
|
// }, [bringToFront, id]);
|
||||||
|
|
||||||
|
// const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
// if (!isDragging || !overlayRef.current) return;
|
||||||
|
// overlayRef.current.style.left = `${e.clientX - offset.x}px`;
|
||||||
|
// overlayRef.current.style.top = `${e.clientY - offset.y}px`;
|
||||||
|
// // Remove fixed positioning classes if dragging manually
|
||||||
|
// overlayRef.current.classList.remove(...Object.values(positionClasses));
|
||||||
|
// }, [isDragging, offset]);
|
||||||
|
|
||||||
|
// const handleMouseUp = useCallback(() => {
|
||||||
|
// if (isDragging) {
|
||||||
|
// setIsDragging(false);
|
||||||
|
// // Optional: Snap to edge or update position state in context
|
||||||
|
// }
|
||||||
|
// }, [isDragging]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (isDragging) {
|
||||||
|
// window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
// window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
// } else {
|
||||||
|
// window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
// window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
// }
|
||||||
|
// return () => {
|
||||||
|
// window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
// window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
// };
|
||||||
|
// }, [isDragging, handleMouseMove, handleMouseUp]);
|
||||||
|
// --- End Optional Dragging Logic ---
|
||||||
|
|
||||||
|
// If the overlay isn't registered yet or isn't open, don't render anything
|
||||||
|
if (!overlay || !overlay.isOpen) return null;
|
||||||
|
|
||||||
|
const handleCloseClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering bringToFront if clicking close
|
||||||
|
closeOverlay(id);
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinimizeClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
minimizeOverlay(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximizeClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
maximizeOverlay(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
bringToFront(id);
|
||||||
|
// handleMouseDown(e); // Uncomment if implementing dragging
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define position classes based on the current state
|
||||||
|
const positionClasses = {
|
||||||
|
"top-left": "top-4 left-4",
|
||||||
|
"top-right": "top-4 right-4",
|
||||||
|
"bottom-left": "bottom-4 left-4",
|
||||||
|
"bottom-right": "bottom-4 right-4",
|
||||||
|
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render minimized state in the dock (handled by OverlayDock now)
|
||||||
|
if (overlay.isMinimized) {
|
||||||
|
// Minimized state is now handled by the OverlayDock component based on context state
|
||||||
|
// This component only renders the full overlay or nothing.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render full overlay
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-10", // z-index is managed by inline style
|
||||||
|
positionClasses[overlay.position], // Apply position classes
|
||||||
|
// Add transition for position changes if needed: 'transition-all duration-300 ease-out'
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ zIndex: overlay.zIndex }} // Apply dynamic z-index
|
||||||
|
onClick={() => bringToFront(id)} // Bring to front on any click within the overlay
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
role="dialog" // Use appropriate role
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"shadow-lg bg-card/95 backdrop-blur-sm border border-border/50 overflow-hidden flex flex-col", // Added flex flex-col
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height, // Use height directly
|
||||||
|
maxHeight, // Use maxHeight
|
||||||
|
maxWidth: "calc(100vw - 32px)", // Prevent overlay exceeding viewport width
|
||||||
|
}}>
|
||||||
|
{/* Make header draggable */}
|
||||||
|
<CardHeader
|
||||||
|
className="pb-2 flex flex-row items-center justify-between cursor-move flex-shrink-0" // Added flex-shrink-0
|
||||||
|
// onMouseDown={handleHeaderMouseDown} // Uncomment if implementing dragging
|
||||||
|
>
|
||||||
|
<CardTitle id={`${id}-title`} className="text-sm font-medium flex items-center gap-2">
|
||||||
|
{icon && <span className="text-primary">{icon}</span>}
|
||||||
|
{title}
|
||||||
|
{/* <Move className="h-3 w-3 text-muted-foreground ml-1 cursor-move" /> */} {/* Optional move icon */}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{showMinimize && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={handleMinimizeClick}
|
||||||
|
title="Minimize">
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={handleCloseClick} title="Close">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{/* Ensure content area takes remaining space and scrolls if needed */}
|
||||||
|
<CardContent className="p-0 flex-1 min-h-0 overflow-auto">{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/features/map/components/property-filters.tsx
Normal file
8
frontend/features/map/components/property-filters.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/components/property-filters.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
// This component seems redundant with filters-overlay.tsx.
|
||||||
|
// Assuming it's removed or its logic is integrated into filters-overlay.tsx.
|
||||||
|
// If needed, its structure would be similar to filters-overlay.tsx but maybe without the <Overlay> wrapper.
|
||||||
38
frontend/features/map/hooks/useMapInteractions.ts
Normal file
38
frontend/features/map/hooks/useMapInteractions.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/hooks/useMapInteractions.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { MapLocation } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY Hook: Manages map interaction state like selected markers, zoom level etc.
|
||||||
|
*/
|
||||||
|
export function useMapInteractions(initialLocation: MapLocation) {
|
||||||
|
const [currentLocation, setCurrentLocation] = useState<MapLocation>(initialLocation);
|
||||||
|
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleMapMove = useCallback((newLocation: MapLocation) => {
|
||||||
|
console.log("DUMMY Hook: Map moved to", newLocation);
|
||||||
|
setCurrentLocation(newLocation);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMarkerClick = useCallback((markerId: string) => {
|
||||||
|
console.log("DUMMY Hook: Marker clicked", markerId);
|
||||||
|
setSelectedMarkerId(markerId);
|
||||||
|
// Potentially fetch details for this marker via API
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedMarkerId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLocation,
|
||||||
|
selectedMarkerId,
|
||||||
|
handleMapMove,
|
||||||
|
handleMarkerClick,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
frontend/features/map/types/index.ts
Normal file
41
frontend/features/map/types/index.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/types/index.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
// Types specific only to the Map feature
|
||||||
|
|
||||||
|
export interface MapBounds {
|
||||||
|
north: number;
|
||||||
|
south: number;
|
||||||
|
east: number;
|
||||||
|
west: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapLocation {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name?: string;
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example type for map layer configuration
|
||||||
|
export interface MapLayerConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string; // e.g., Tile server URL template
|
||||||
|
type: "raster" | "vector" | "geojson";
|
||||||
|
visible: boolean;
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example type for data displayed on the map
|
||||||
|
export interface MapPropertyData {
|
||||||
|
id: string;
|
||||||
|
coordinates: [number, number]; // [lng, lat]
|
||||||
|
price: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export relevant shared types if needed for convenience
|
||||||
|
// export type { PointOfInterest } from '@/types/api';
|
||||||
26
frontend/features/map/utils/mapHelpers.ts
Normal file
26
frontend/features/map/utils/mapHelpers.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/map/utils/mapHelpers.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MapBounds, MapLocation } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY Utility: Calculates the center of given map bounds.
|
||||||
|
*/
|
||||||
|
export function calculateBoundsCenter(bounds: MapBounds): MapLocation {
|
||||||
|
const centerLat = (bounds.north + bounds.south) / 2;
|
||||||
|
const centerLng = (bounds.east + bounds.west) / 2;
|
||||||
|
console.log("DUMMY Util: Calculating center for bounds:", bounds);
|
||||||
|
return { lat: centerLat, lng: centerLng };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY Utility: Formats coordinates for display.
|
||||||
|
*/
|
||||||
|
export function formatCoords(location: MapLocation): string {
|
||||||
|
return `${location.lat.toFixed(4)}, ${location.lng.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other map-specific utility functions here
|
||||||
51
frontend/features/model-explanation/api/explanationApi.ts
Normal file
51
frontend/features/model-explanation/api/explanationApi.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/model-explanation/api/explanationApi.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import apiClient from "@/services/apiClient";
|
||||||
|
import type { APIResponse } from "@/types/api";
|
||||||
|
import type { ModelExplanationData, FeatureImportance } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DUMMY: Fetches data needed for the model explanation page.
|
||||||
|
*/
|
||||||
|
export async function fetchModelExplanation(propertyId: string): Promise<APIResponse<ModelExplanationData>> {
|
||||||
|
console.log(`DUMMY API: Fetching model explanation for property ID: ${propertyId}`);
|
||||||
|
// return apiClient.get<ModelExplanationData>(`/properties/${propertyId}/explanation`);
|
||||||
|
|
||||||
|
// Simulate response
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||||
|
const dummyExplanation: ModelExplanationData = {
|
||||||
|
propertyDetails: {
|
||||||
|
address: `Dummy Property ${propertyId}`,
|
||||||
|
type: "Condo",
|
||||||
|
size: 120,
|
||||||
|
bedrooms: 2,
|
||||||
|
bathrooms: 2,
|
||||||
|
age: 3,
|
||||||
|
floor: 10,
|
||||||
|
amenities: ["Pool", "Gym"],
|
||||||
|
predictedPrice: 12500000,
|
||||||
|
},
|
||||||
|
similarProperties: [
|
||||||
|
{ address: "Comp 1", price: 12000000, size: 115, age: 4 },
|
||||||
|
{ address: "Comp 2", price: 13500000, size: 130, age: 2 },
|
||||||
|
],
|
||||||
|
features: [
|
||||||
|
{ name: "Location", importance: 40, value: "Near BTS", impact: "positive" },
|
||||||
|
{ name: "Size", importance: 30, value: "120 sqm", impact: "positive" },
|
||||||
|
{ name: "Age", importance: 15, value: "3 years", impact: "neutral" },
|
||||||
|
{ name: "Amenities", importance: 10, value: "Pool, Gym", impact: "positive" },
|
||||||
|
{ name: "Floor", importance: 5, value: "10th", impact: "positive" },
|
||||||
|
],
|
||||||
|
environmentalFactors: {
|
||||||
|
floodRisk: "low",
|
||||||
|
airQuality: "moderate",
|
||||||
|
noiseLevel: "low",
|
||||||
|
},
|
||||||
|
confidence: 0.91,
|
||||||
|
priceRange: { lower: 11800000, upper: 13200000 },
|
||||||
|
};
|
||||||
|
return { success: true, data: dummyExplanation };
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/model-explanation/components/feature-importance-chart.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "@/components/ui/chart"; // Using shared ui chart components
|
||||||
|
import type { FeatureImportance } from "../types"; // Feature specific type
|
||||||
|
|
||||||
|
interface FeatureImportanceChartProps {
|
||||||
|
features: FeatureImportance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureImportanceChart({ features }: FeatureImportanceChartProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
// Sort features by importance for consistent display
|
||||||
|
const sortedFeatures = [...features].sort((a, b) => b.importance - a.importance);
|
||||||
|
|
||||||
|
// Define colors based on impact
|
||||||
|
const getBarColor = (impact: "positive" | "negative" | "neutral") => {
|
||||||
|
if (impact === "positive") return "#10b981"; // Green
|
||||||
|
if (impact === "negative") return "#ef4444"; // Red
|
||||||
|
return "#f59e0b"; // Amber/Yellow for neutral
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={sortedFeatures} layout="vertical" margin={{ top: 5, right: 30, left: 80, bottom: 5 }}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false} // Typically vertical grid lines are less useful for horizontal bar charts
|
||||||
|
stroke={isDark ? "#374151" : "#e5e7eb"}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0, 100]} // Assuming importance is a percentage
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
stroke={isDark ? "#9ca3af" : "#6b7280"}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="name"
|
||||||
|
type="category"
|
||||||
|
width={80} // Adjust width based on longest label
|
||||||
|
stroke={isDark ? "#9ca3af" : "#6b7280"}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value.toFixed(1)}%`, "Importance"]}
|
||||||
|
labelFormatter={(label: string) => `Feature: ${label}`} // Show feature name in tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: isDark ? "#1f2937" : "white",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: isDark ? "1px solid #374151" : "1px solid #e2e8f0",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: isDark ? "#e5e7eb" : "#1f2937",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
|
||||||
|
{sortedFeatures.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getBarColor(entry.impact)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/model-explanation/components/price-comparison-chart.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
Cell,
|
||||||
|
} from "@/components/ui/chart"; // Using shared ui chart components
|
||||||
|
import type { ComparableProperty, PropertyBaseDetails } from "../types"; // Feature specific types
|
||||||
|
|
||||||
|
interface PriceComparisonChartProps {
|
||||||
|
property: PropertyBaseDetails & { name: string }; // Add name for the primary property
|
||||||
|
comparisons: ComparableProperty[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceComparisonChart({ property, comparisons }: PriceComparisonChartProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
// Combine property and comparisons for chart data
|
||||||
|
// Ensure the property being explained is included and identifiable
|
||||||
|
const data = [
|
||||||
|
{ ...property }, // Keep all details for tooltip if needed
|
||||||
|
...comparisons.map((c) => ({ ...c, name: c.address })), // Use address as name for comparisons
|
||||||
|
];
|
||||||
|
|
||||||
|
// Format the price for display
|
||||||
|
const formatPrice = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("th-TH", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "THB",
|
||||||
|
notation: "compact", // Use compact notation like 15M
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom tooltip content
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload; // Get the data point for this bar
|
||||||
|
return (
|
||||||
|
<div className="p-2 bg-background border rounded shadow-lg text-xs">
|
||||||
|
<p className="font-bold">{label}</p>
|
||||||
|
<p>Price: {formatPrice(data.price)}</p>
|
||||||
|
<p>Size: {data.size} sqm</p>
|
||||||
|
<p>Age: {data.age} years</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} margin={{ top: 5, right: 10, left: 40, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={isDark ? "#374151" : "#e5e7eb"} />
|
||||||
|
<XAxis dataKey="name" stroke={isDark ? "#9ca3af" : "#6b7280"} fontSize={10} interval={0} />
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value) => formatPrice(value)}
|
||||||
|
stroke={isDark ? "#9ca3af" : "#6b7280"}
|
||||||
|
fontSize={10}
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: isDark ? "rgba(107, 114, 128, 0.2)" : "rgba(209, 213, 219, 0.4)" }} // Subtle hover effect
|
||||||
|
content={<CustomTooltip />} // Use custom tooltip
|
||||||
|
wrapperStyle={{ zIndex: 100 }} // Ensure tooltip is on top
|
||||||
|
/>
|
||||||
|
{/* <Legend /> // Legend might be redundant if XAxis labels are clear */}
|
||||||
|
<Bar dataKey="price" name="Price" radius={[4, 4, 0, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.name === property.name ? "#3b82f6" : "#6b7280"} // Highlight the main property
|
||||||
|
fillOpacity={entry.name === property.name ? 1 : 0.7}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/features/model-explanation/types/index.ts
Normal file
50
frontend/features/model-explanation/types/index.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/features/model-explanation/types/index.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types specific to the Model Explanation feature
|
||||||
|
|
||||||
|
export interface FeatureImportance {
|
||||||
|
name: string;
|
||||||
|
importance: number; // e.g., percentage 0-100
|
||||||
|
value: string | number; // The actual value for the property being explained
|
||||||
|
impact: "positive" | "negative" | "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparableProperty {
|
||||||
|
address: string;
|
||||||
|
price: number;
|
||||||
|
size: number;
|
||||||
|
age: number;
|
||||||
|
// Add other relevant comparison fields if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertyBaseDetails {
|
||||||
|
address: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
bedrooms?: number;
|
||||||
|
bathrooms?: number;
|
||||||
|
age: number;
|
||||||
|
floor?: number;
|
||||||
|
amenities?: string[];
|
||||||
|
predictedPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentalFactors {
|
||||||
|
floodRisk: "low" | "moderate" | "high" | "unknown";
|
||||||
|
airQuality: "good" | "moderate" | "poor" | "unknown";
|
||||||
|
noiseLevel: "low" | "moderate" | "high" | "unknown";
|
||||||
|
// Add other factors like proximity scores etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelExplanationData {
|
||||||
|
propertyDetails: PropertyBaseDetails;
|
||||||
|
similarProperties: ComparableProperty[];
|
||||||
|
features: FeatureImportance[];
|
||||||
|
environmentalFactors: EnvironmentalFactors;
|
||||||
|
confidence: number; // e.g., 0.92 for 92%
|
||||||
|
priceRange: { lower: number; upper: number };
|
||||||
|
}
|
||||||
@ -1,19 +1,37 @@
|
|||||||
import * as React from "react"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/hooks/use-mobile.tsx
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768; // Standard md breakpoint
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile(): boolean {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
// Initialize state based on current window size (or undefined if SSR)
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : undefined
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
// Ensure this runs only client-side
|
||||||
const onChange = () => {
|
if (typeof window === "undefined") {
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
return;
|
||||||
}
|
}
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial state correctly after mount
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Cleanup listener on unmount
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []); // Empty dependency array ensures this runs only once on mount and cleanup on unmount
|
||||||
|
|
||||||
|
// Return false during SSR or initial client render before effect runs
|
||||||
|
return isMobile ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,106 +1,106 @@
|
|||||||
"use client"
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/hooks/use-toast.ts
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
// Import types from the actual Toast component location
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
|
|
||||||
import type {
|
const TOAST_LIMIT = 1; // Show only one toast at a time
|
||||||
ToastActionElement,
|
const TOAST_REMOVE_DELAY = 1000000; // A very long time (effectively manual dismiss only)
|
||||||
ToastProps,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
type ToasterToast = ToastProps & {
|
||||||
id: string
|
id: string;
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode;
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode;
|
||||||
action?: ToastActionElement
|
action?: ToastActionElement;
|
||||||
}
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
ADD_TOAST: "ADD_TOAST",
|
ADD_TOAST: "ADD_TOAST",
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
let count = 0
|
let count = 0;
|
||||||
|
|
||||||
function genId() {
|
function genId() {
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
return count.toString()
|
return count.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"]
|
type: ActionType["ADD_TOAST"];
|
||||||
toast: ToasterToast
|
toast: ToasterToast;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"]
|
type: ActionType["UPDATE_TOAST"];
|
||||||
toast: Partial<ToasterToast>
|
toast: Partial<ToasterToast>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"]
|
type: ActionType["DISMISS_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"]
|
type: ActionType["REMOVE_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
};
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
toasts: ToasterToast[]
|
toasts: ToasterToast[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
if (toastTimeouts.has(toastId)) {
|
if (toastTimeouts.has(toastId)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId)
|
toastTimeouts.delete(toastId);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: "REMOVE_TOAST",
|
||||||
toastId: toastId,
|
toastId: toastId,
|
||||||
})
|
});
|
||||||
}, TOAST_REMOVE_DELAY)
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
toastTimeouts.set(toastId, timeout);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ADD_TOAST":
|
case "ADD_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
// Slice ensures the limit is enforced
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
}
|
};
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case "UPDATE_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
};
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case "DISMISS_TOAST": {
|
||||||
const { toastId } = action
|
const { toastId } = action;
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
// Side effect: schedule removal for dismissed toasts
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
addToRemoveQueue(toastId)
|
addToRemoveQueue(toastId);
|
||||||
} else {
|
} else {
|
||||||
state.toasts.forEach((toast) => {
|
state.toasts.forEach((toast) => {
|
||||||
addToRemoveQueue(toast.id)
|
addToRemoveQueue(toast.id);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -109,48 +109,48 @@ export const reducer = (state: State, action: Action): State => {
|
|||||||
t.id === toastId || toastId === undefined
|
t.id === toastId || toastId === undefined
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
open: false,
|
open: false, // Trigger the close animation
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case "REMOVE_TOAST":
|
case "REMOVE_TOAST":
|
||||||
if (action.toastId === undefined) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [],
|
toasts: [], // Remove all toasts
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
let memoryState: State = { toasts: [] }; // In-memory state
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
memoryState = reducer(memoryState, action)
|
memoryState = reducer(memoryState, action);
|
||||||
listeners.forEach((listener) => {
|
listeners.forEach((listener) => {
|
||||||
listener(memoryState)
|
listener(memoryState);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ ...props }: Toast) {
|
||||||
const id = genId()
|
const id = genId();
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: "UPDATE_TOAST",
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
})
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: "ADD_TOAST",
|
||||||
@ -159,36 +159,37 @@ function toast({ ...props }: Toast) {
|
|||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open) => {
|
||||||
if (!open) dismiss()
|
if (!open) dismiss(); // Ensure dismiss is called when the toast closes itself
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
dismiss,
|
dismiss,
|
||||||
update,
|
update,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useToast() {
|
function useToast() {
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listeners.push(setState)
|
listeners.push(setState);
|
||||||
return () => {
|
return () => {
|
||||||
const index = listeners.indexOf(setState)
|
// Clean up listener
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listeners.splice(index, 1)
|
listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [state])
|
}, [state]); // Only re-subscribe if state instance changes (it shouldn't)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
toast,
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useToast, toast }
|
export { useToast, toast };
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
/*
|
||||||
import { twMerge } from "tailwind-merge"
|
========================================
|
||||||
|
File: frontend/lib/utils.ts
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/** Utility function to merge Tailwind classes with clsx */
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add other general utility functions here if needed
|
||||||
|
|||||||
@ -1,7 +1,22 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
reactStrictMode: true, // Recommended for development
|
||||||
|
// Add other Next.js configurations here as needed
|
||||||
|
// Example: environment variables accessible on the client-side
|
||||||
|
// env: {
|
||||||
|
// NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||||
|
// },
|
||||||
|
// Example: image optimization domains
|
||||||
|
// images: {
|
||||||
|
// remotePatterns: [
|
||||||
|
// {
|
||||||
|
// protocol: 'https',
|
||||||
|
// hostname: 'example.com',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@ -61,7 +61,8 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
@ -171,6 +171,9 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.24.2
|
version: 3.24.2
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.3
|
||||||
|
version: 5.0.3(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3
|
specifier: ^3
|
||||||
@ -2926,6 +2929,24 @@ packages:
|
|||||||
zod@3.24.2:
|
zod@3.24.2:
|
||||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
||||||
|
|
||||||
|
zustand@5.0.3:
|
||||||
|
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@ -5939,3 +5960,9 @@ snapshots:
|
|||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod@3.24.2: {}
|
zod@3.24.2: {}
|
||||||
|
|
||||||
|
zustand@5.0.3(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0)):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
react: 19.0.0
|
||||||
|
use-sync-external-store: 1.4.0(react@19.0.0)
|
||||||
|
|||||||
106
frontend/services/apiClient.ts
Normal file
106
frontend/services/apiClient.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/services/apiClient.ts (NEW - Dummy)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import type { APIResponse } from "@/types/api"; // Import shared response type
|
||||||
|
|
||||||
|
// --- Dummy Auth Token ---
|
||||||
|
// In a real app, this would come from localStorage, context, or a state manager after login
|
||||||
|
const DUMMY_AUTH_TOKEN = "Bearer dummy-jwt-token-12345";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base function for making API requests.
|
||||||
|
* Includes dummy authorization header.
|
||||||
|
*/
|
||||||
|
async function fetchApi<T = any>(endpoint: string, options: RequestInit = {}): Promise<APIResponse<T>> {
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_API_URL || "/api/v1"}${endpoint}`;
|
||||||
|
|
||||||
|
const defaultHeaders: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: DUMMY_AUTH_TOKEN, // Add dummy token here
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`DUMMY API Client: Requesting ${config.method || "GET"} ${url}`);
|
||||||
|
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.random() * 300 + 100));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Simulate API Responses ---
|
||||||
|
// You can add more sophisticated simulation based on the endpoint
|
||||||
|
if (endpoint.includes("error")) {
|
||||||
|
console.warn(`DUMMY API Client: Simulating error for ${url}`);
|
||||||
|
return { success: false, error: "Simulated server error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.method === "POST" || config.method === "PUT" || config.method === "DELETE") {
|
||||||
|
// Simulate simple success for modification requests
|
||||||
|
console.log(`DUMMY API Client: Simulating success for ${config.method} ${url}`);
|
||||||
|
return { success: true, data: { message: "Operation successful (simulated)" } as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate success for GET requests (return empty array or specific data based on endpoint)
|
||||||
|
console.log(`DUMMY API Client: Simulating success for GET ${url}`);
|
||||||
|
let responseData: any = [];
|
||||||
|
if (endpoint.includes("/map/pois")) responseData = []; // Let feature api add dummy data
|
||||||
|
if (endpoint.includes("/properties")) responseData = []; // Example
|
||||||
|
// Add more specific endpoint data simulation if needed
|
||||||
|
|
||||||
|
return { success: true, data: responseData as T };
|
||||||
|
|
||||||
|
// --- Real Fetch Logic (keep commented for dummy) ---
|
||||||
|
// const response = await fetch(url, config);
|
||||||
|
//
|
||||||
|
// if (!response.ok) {
|
||||||
|
// let errorData;
|
||||||
|
// try {
|
||||||
|
// errorData = await response.json();
|
||||||
|
// } catch (e) {
|
||||||
|
// errorData = { detail: response.statusText || "Unknown error" };
|
||||||
|
// }
|
||||||
|
// const errorMessage = errorData?.detail || `HTTP error ${response.status}`;
|
||||||
|
// console.error(`API Error (${response.status}) for ${url}:`, errorMessage);
|
||||||
|
// return { success: false, error: errorMessage, details: errorData };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Handle cases with no content
|
||||||
|
// if (response.status === 204) {
|
||||||
|
// return { success: true, data: null as T };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// const data: T = await response.json();
|
||||||
|
// return { success: true, data };
|
||||||
|
// --- End Real Fetch Logic ---
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Network or other error for ${url}:`, error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Network error or invalid response";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Convenience Methods ---
|
||||||
|
const apiClient = {
|
||||||
|
get: <T = any>(endpoint: string, options?: RequestInit) => fetchApi<T>(endpoint, { ...options, method: "GET" }),
|
||||||
|
|
||||||
|
post: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
|
||||||
|
fetchApi<T>(endpoint, { ...options, method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
put: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
|
||||||
|
fetchApi<T>(endpoint, { ...options, method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
delete: <T = any>(endpoint: string, options?: RequestInit) => fetchApi<T>(endpoint, { ...options, method: "DELETE" }),
|
||||||
|
|
||||||
|
patch: <T = any>(endpoint: string, body: any, options?: RequestInit) =>
|
||||||
|
fetchApi<T>(endpoint, { ...options, method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
43
frontend/store/mapStore.ts
Normal file
43
frontend/store/mapStore.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/store/mapStore.ts (NEW - Dummy using Zustand)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type { MapLocation, MapLayerConfig } from "@/features/map/types"; // Import types
|
||||||
|
|
||||||
|
interface MapState {
|
||||||
|
currentLocation: MapLocation;
|
||||||
|
zoomLevel: number;
|
||||||
|
activeLayers: MapLayerConfig[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setLocation: (location: MapLocation) => void;
|
||||||
|
setZoom: (zoom: number) => void;
|
||||||
|
toggleLayer: (layerId: string) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMapStore = create<MapState>((set) => ({
|
||||||
|
// Initial State
|
||||||
|
currentLocation: { lat: 13.7563, lng: 100.5018, name: "Bangkok" },
|
||||||
|
zoomLevel: 12,
|
||||||
|
activeLayers: [
|
||||||
|
// Example initial layer
|
||||||
|
{ id: "base-tiles", name: "Base Map", url: "dummy-tile-url", type: "raster", visible: true },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setLocation: (location) => set({ currentLocation: location }),
|
||||||
|
setZoom: (zoom) => set({ zoomLevel: zoom }),
|
||||||
|
toggleLayer: (layerId) =>
|
||||||
|
set((state) => ({
|
||||||
|
activeLayers: state.activeLayers.map((layer) =>
|
||||||
|
layer.id === layerId ? { ...layer, visible: !layer.visible } : layer
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Usage in a component:
|
||||||
|
// const { currentLocation, setLocation, isLoading } = useMapStore();
|
||||||
@ -1,15 +1,17 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme"; // Import default theme
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"], // Use class-based dark mode
|
||||||
content: [
|
content: [
|
||||||
"./pages/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}", // Scan app directory
|
||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}", // Scan components directory (common and ui)
|
||||||
"./app/**/*.{ts,tsx}",
|
"./features/**/*.{ts,tsx}", // <= NEW: Scan features directory
|
||||||
"./src/**/*.{ts,tsx}",
|
// Remove older paths if no longer relevant
|
||||||
"*.{js,ts,jsx,tsx,mdx}",
|
// "./pages/**/*.{ts,tsx}", // Likely remove if using App Router only
|
||||||
|
// "./src/**/*.{ts,tsx}", // Remove if code is not in src/
|
||||||
],
|
],
|
||||||
prefix: "",
|
prefix: "", // No prefix
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
@ -19,7 +21,12 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
// Add sans-serif font family using CSS variable defined in layout.tsx
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
// Keep Shadcn UI color definitions using CSS variables
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
@ -53,15 +60,16 @@ const config = {
|
|||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
// Sidebar specific colors (ensure these variables are defined in globals.css)
|
||||||
sidebar: {
|
sidebar: {
|
||||||
DEFAULT: "hsl(var(--sidebar-background))",
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
foreground: "hsl(var(--sidebar-foreground))",
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
primary: "hsl(var(--sidebar-primary))",
|
|
||||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
|
||||||
accent: "hsl(var(--sidebar-accent))",
|
|
||||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
|
||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
ring: "hsl(var(--sidebar-ring))",
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-accent))",
|
||||||
|
foreground: "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
@ -78,14 +86,20 @@ const config = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
|
// Add caret-blink keyframes if not already present via a plugin
|
||||||
|
"caret-blink": {
|
||||||
|
"0%, 50%, 100%": { opacity: "1" },
|
||||||
|
"25%, 75%": { opacity: "0" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
"caret-blink": "caret-blink 1s ease-in-out infinite", // Add caret animation
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")], // Keep animate plugin
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@ -1,27 +1,56 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017", // Keep target
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true, // Keep strict mode
|
||||||
"noEmit": true,
|
"noEmit": true, // Next.js handles emitting
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext", // Use esnext module system
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler", // Recommended for modern TS/JS
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve", // Let Next.js handle JSX transform
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Ensure path aliases cover the new structure
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"], // Base alias
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/lib/*": ["./lib/*"],
|
||||||
|
"@/hooks/*": ["./hooks/*"],
|
||||||
|
"@/features/*": ["./features/*"],
|
||||||
|
"@/types/*": ["./types/*"],
|
||||||
|
"@/services/*": ["./services/*"],
|
||||||
|
"@/store/*": ["./store/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
// Update include paths
|
||||||
"exclude": ["node_modules"]
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
// Add specific includes if needed, but '**/*.ts/tsx' usually covers it
|
||||||
|
// "features/**/*.ts",
|
||||||
|
// "features/**/*.tsx",
|
||||||
|
// "components/**/*.ts",
|
||||||
|
// "components/**/*.tsx",
|
||||||
|
// "lib/**/*.ts",
|
||||||
|
// "hooks/**/*.ts",
|
||||||
|
// "types/**/*.ts",
|
||||||
|
// "services/**/*.ts",
|
||||||
|
// "store/**/*.ts",
|
||||||
|
"eslint.config.mjs", // Include modern ESLint config
|
||||||
|
"postcss.config.mjs" // Include modern PostCSS config
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
// Add other excludes if necessary
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/types/api.ts
Normal file
41
frontend/types/api.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/types/api.ts (NEW - Dummy Shared Types)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Generic API Response Structure */
|
||||||
|
export type APIResponse<T> = { success: true; data: T } | { success: false; error: string; details?: any };
|
||||||
|
|
||||||
|
/** Represents a Point of Interest (can be property, cafe, etc.) */
|
||||||
|
export interface PointOfInterest {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name: string;
|
||||||
|
type: string; // e.g., 'property', 'cafe', 'park', 'station'
|
||||||
|
price?: number; // Optional: for properties
|
||||||
|
rating?: number; // Optional: for amenities
|
||||||
|
// Add other relevant shared fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basic Property Information */
|
||||||
|
export interface PropertySummary {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
price: number;
|
||||||
|
type: string; // e.g., 'Condo', 'House'
|
||||||
|
size?: number; // sqm
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User representation */
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
// Add roles or other relevant fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other globally shared types (e.g., PipelineStatus, DataSourceType if needed FE side)
|
||||||
12
frontend/types/index.ts
Normal file
12
frontend/types/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
========================================
|
||||||
|
File: frontend/types/index.ts (NEW - Barrel File)
|
||||||
|
========================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export shared types for easier importing
|
||||||
|
export * from "./api";
|
||||||
|
|
||||||
|
// You can add other shared types here or export from other files in this directory
|
||||||
|
// export * from './user';
|
||||||
|
// export * from './settings';
|
||||||
Loading…
Reference in New Issue
Block a user