mirror of
https://github.com/borbann-platform/backend-api.git
synced 2025-12-18 12:14: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).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```markdown
|
||||
frontend/
|
||||
├── features/ # <= NEW: Feature-specific modules
|
||||
│ ├── map/
|
||||
│ │ ├── api/ # API calls specific to the map
|
||||
│ │ │ └── mapApi.ts # (Example)
|
||||
│ │ ├── components/ # Map-specific UI components
|
||||
│ │ │ ├── analytics-overlay.tsx
|
||||
│ │ │ ├── analytics-panel.tsx
|
||||
│ │ │ ├── area-chart.tsx
|
||||
│ │ │ ├── chat-bot.tsx
|
||||
│ │ │ ├── chat-overlay.tsx
|
||||
│ │ │ ├── filters-overlay.tsx
|
||||
│ │ │ ├── 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;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss" prefix(tw);
|
||||
|
||||
@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 {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--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;
|
||||
|
||||
/* Sidebar specific colors */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
@ -34,38 +134,36 @@
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Overlay constraints */
|
||||
--max-overlay-width: calc(100vw - 32px);
|
||||
--max-overlay-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
|
||||
/* Sidebar specific colors for dark mode */
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--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-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 0 0% 98%;
|
||||
--sidebar-primary-foreground: 240 5.9% 10%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
@ -81,25 +179,3 @@
|
||||
@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 = {
|
||||
title: 'v0 App',
|
||||
description: 'Created with v0',
|
||||
generator: 'v0.dev',
|
||||
}
|
||||
title: "Borbann - Data Platform", // More specific title
|
||||
description: "Data integration, analysis, and visualization platform.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
{" "}
|
||||
{/* 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 Link from "next/link"; // Import Link
|
||||
import { Button } from "@/components/ui/button"; // Import common UI
|
||||
|
||||
export default function Home() {
|
||||
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)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
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 flex-col min-h-screen items-center justify-center p-8 sm:p-20 text-center">
|
||||
{/* Replace with your actual logo/branding */}
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Borbann</h1>
|
||||
<p className="text-lg text-muted-foreground mb-8">Your data integration and analysis platform.</p>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
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"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||||
<Link href="/map">
|
||||
<Button size="lg">Go to Map</Button>
|
||||
</Link>
|
||||
<Link href="/documentation">
|
||||
{" "}
|
||||
{/* Example link */}
|
||||
<Button size="lg" variant="outline">
|
||||
Read Docs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Optional: Add more introductory content or links */}
|
||||
<footer className="absolute bottom-8 text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Borbann Project.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
@ -15,7 +15,10 @@
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
"hooks": "@/hooks",
|
||||
"features": "@/features",
|
||||
"types": "@/types",
|
||||
"services": "@/services"
|
||||
},
|
||||
"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 { useTheme } from "next-themes"
|
||||
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Sun, Moon, Laptop, Palette, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -11,70 +16,65 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Define available color schemes
|
||||
// Define available color schemes (these affect CSS variables)
|
||||
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: "Purple", primary: "262.1 83.3% 57.8%" },
|
||||
{ name: "Orange", primary: "24.6 95% 53.1%" },
|
||||
{ name: "Teal", primary: "173 80.4% 40%" },
|
||||
]
|
||||
];
|
||||
|
||||
interface ThemeControllerProps {
|
||||
children: ReactNode
|
||||
defaultColorScheme?: string
|
||||
children: ReactNode;
|
||||
defaultColorScheme?: string;
|
||||
}
|
||||
|
||||
export function ThemeController({ children, defaultColorScheme = "Blue" }: ThemeControllerProps) {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const [colorScheme, setColorScheme] = useState(defaultColorScheme)
|
||||
const [overlayBoundaries, setOverlayBoundaries] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [colorScheme, setColorScheme] = useState(defaultColorScheme);
|
||||
// State for overlay boundaries removed, as overlay context now manages positioning/constraints.
|
||||
// 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(() => {
|
||||
const updateBoundaries = () => {
|
||||
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)
|
||||
const scheme = colorSchemes.find((s) => s.name === colorScheme);
|
||||
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])
|
||||
|
||||
// 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])
|
||||
}, [colorScheme]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden" data-theme-controller="true">
|
||||
{children}
|
||||
|
||||
{/* 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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@ -83,8 +83,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@ -123,8 +122,7 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
||||
<DropdownMenuItem
|
||||
key={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="h-4 w-4 rounded-full" style={{ backgroundColor: `hsl(${scheme.primary})` }} />
|
||||
<span>{scheme.name}</span>
|
||||
@ -142,6 +140,5 @@ export function ThemeController({ children, defaultColorScheme = "Blue" }: Theme
|
||||
</TooltipProvider>
|
||||
</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 { useTheme } from "next-themes"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import React from "react";
|
||||
import { Moon, Sun, Laptop } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@ -42,6 +53,5 @@ export function ThemeToggle() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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 { fileURLToPath } from "url";
|
||||
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 __dirname = dirname(__filename);
|
||||
|
||||
// FlatCompat for extending older eslintrc-style configs if needed (like next/core-web-vitals)
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
// resolvePluginsRelativeTo: __dirname, // might be needed depending on setup
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AreaChart } from "./area-chart"
|
||||
import { Overlay } from "./overlay-system/overlay"
|
||||
import { useOverlay } from "./overlay-system/overlay-context"
|
||||
import { LineChart, Wind, Droplets, Sparkles, Bot } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AreaChart } from "./area-chart";
|
||||
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
|
||||
import { useOverlay } from "./overlay-system/overlay-context";
|
||||
|
||||
export function AnalyticsOverlay() {
|
||||
const { toggleOverlay } = useOverlay()
|
||||
const { toggleOverlay } = useOverlay();
|
||||
|
||||
const handleChatClick = () => {
|
||||
toggleOverlay("chat")
|
||||
}
|
||||
toggleOverlay("chat");
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
@ -20,13 +25,13 @@ export function AnalyticsOverlay() {
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
initialPosition="top-right"
|
||||
initialIsOpen={true}
|
||||
width="350px"
|
||||
>
|
||||
width="350px">
|
||||
<div className="h-[calc(min(70vh,600px))] overflow-auto">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
@ -46,6 +51,7 @@ export function AnalyticsOverlay() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price Prediction 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">
|
||||
@ -65,6 +71,7 @@ export function AnalyticsOverlay() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Environmental Factors Cards */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-card/50 border border-border/50 shadow-sm">
|
||||
<CardHeader className="p-4">
|
||||
@ -93,10 +100,10 @@ export function AnalyticsOverlay() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chat With AI Card */}
|
||||
<Card
|
||||
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">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-teal-500" />
|
||||
@ -109,6 +116,5 @@ export function AnalyticsOverlay() {
|
||||
</div>
|
||||
</div>
|
||||
</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 { useTheme } from "next-themes"
|
||||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from "@/components/ui/chart"; // Using shared ui chart components
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface AreaChartProps {
|
||||
data: number[]
|
||||
color: string
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function AreaChart({ data, color }: AreaChartProps) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === "dark"
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// Generate labels (months)
|
||||
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
|
||||
// Generate labels (e.g., months or simple indices)
|
||||
const labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]; // Example labels
|
||||
|
||||
// Format the data for the chart
|
||||
const chartData = data.map((value, index) => ({
|
||||
name: labels[index],
|
||||
name: labels[index % labels.length] || `Point ${index + 1}`, // Use labels or fallback
|
||||
value: value,
|
||||
}))
|
||||
}));
|
||||
|
||||
// Format the price for display
|
||||
// Format the price for display in tooltip
|
||||
const formatPrice = (value: number) => {
|
||||
return new Intl.NumberFormat("th-TH", {
|
||||
style: "currency",
|
||||
currency: "THB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[80px] w-full">
|
||||
{" "}
|
||||
{/* Adjust height as needed */}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<XAxis dataKey="name" hide />
|
||||
@ -50,16 +57,17 @@ export function AreaChart({ data, color }: AreaChartProps) {
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")}
|
||||
stroke={color.replace("rgba", "rgb").replace(/,[^,]*\)/, ")")} // Ensure valid RGB for stroke
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 1 }}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
fill={color}
|
||||
fillOpacity={0.5}
|
||||
// Area charts typically use <Area>, but keeping <Line> based on original code
|
||||
// If area fill is desired:
|
||||
// fill={color}
|
||||
// fillOpacity={0.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</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 { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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 { Minimize2, Maximize2 } from "lucide-react"
|
||||
import { useOverlayContext } from "./overlay-context"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Overlay } from "./overlay-system/overlay"; // Import local overlay system
|
||||
|
||||
export function PropertyFilters() {
|
||||
const { overlays, minimizeOverlay, maximizeOverlay } = useOverlayContext()
|
||||
const isMinimized = overlays.filters.minimized
|
||||
export function FiltersOverlay() {
|
||||
const [area, setArea] = useState("< 30 km");
|
||||
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 [timePeriod, setTimePeriod] = useState("All Time")
|
||||
const [propertyType, setPropertyType] = useState("House")
|
||||
const [priceRange, setPriceRange] = useState([5000000, 20000000])
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
|
||||
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">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>
|
||||
)
|
||||
}
|
||||
const handleApplyFilters = () => {
|
||||
console.log("DUMMY: Applying filters:", {
|
||||
area,
|
||||
timePeriod,
|
||||
propertyType,
|
||||
priceRange, // Include advanced filters state here
|
||||
});
|
||||
// In real app: trigger data refetch with these filters
|
||||
};
|
||||
|
||||
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">Property Filters</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0" onClick={() => minimizeOverlay("filters")}>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<ScrollArea className="h-full max-h-[calc(min(80vh,var(--max-overlay-height))-60px)]">
|
||||
<CardContent className="p-4">
|
||||
<Overlay
|
||||
id="filters"
|
||||
title="Property Filters"
|
||||
icon={<Filter className="h-5 w-5" />}
|
||||
initialPosition="bottom-left"
|
||||
initialIsOpen={true}
|
||||
width="350px">
|
||||
<ScrollArea className="h-[calc(min(70vh,500px))]">
|
||||
{" "}
|
||||
{/* Scrollable content */}
|
||||
<div className="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>
|
||||
@ -54,9 +54,11 @@ export function PropertyFilters() {
|
||||
<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>
|
||||
<Label htmlFor="area-radius" className="text-xs font-medium">
|
||||
Area Radius
|
||||
</Label>
|
||||
<Select value={area} onValueChange={setArea}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="area-radius">
|
||||
<SelectValue placeholder="Select area" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -68,9 +70,11 @@ export function PropertyFilters() {
|
||||
</Select>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="time-period">
|
||||
<SelectValue placeholder="Select time period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -83,9 +87,11 @@ export function PropertyFilters() {
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="property-type">
|
||||
<SelectValue placeholder="Select property type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -103,34 +109,26 @@ export function PropertyFilters() {
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<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">
|
||||
{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])}
|
||||
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[0])} -{" "}
|
||||
{new Intl.NumberFormat("th-TH", { notation: "compact" }).format(priceRange[1])} ฿
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="price-range"
|
||||
value={priceRange}
|
||||
min={1000000}
|
||||
max={50000000}
|
||||
step={1000000}
|
||||
min={1_000_000}
|
||||
max={50_000_000}
|
||||
step={100_000} // Finer step
|
||||
onValueChange={setPriceRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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="flex items-center justify-between">
|
||||
<Label htmlFor="low-flood" className="text-xs">
|
||||
@ -156,12 +154,11 @@ export function PropertyFilters() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button className="mt-4 w-full" size="sm">
|
||||
<Button className="mt-4 w-full" size="sm" onClick={handleApplyFilters}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</div>
|
||||
</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 Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/common/ThemeToggle"; // Import from common
|
||||
|
||||
export function MapHeader() {
|
||||
// Add any map-specific header logic here if needed
|
||||
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">
|
||||
<Link href="/tools" className="hover:text-foreground">
|
||||
{" "}
|
||||
{/* Example link */}
|
||||
Tools
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">Map</span>
|
||||
</div>
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
|
||||
<Button variant="outline" size="sm" className="ml-2">
|
||||
Buy & Sell
|
||||
Dummy Action 1
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Send & Receive
|
||||
Dummy Action 2
|
||||
</Button>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react";
|
||||
@ -20,7 +25,7 @@ export interface OverlayState {
|
||||
// Interface for the overlay context
|
||||
interface OverlayContextType {
|
||||
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;
|
||||
openOverlay: (id: OverlayId) => void;
|
||||
closeOverlay: (id: OverlayId) => void;
|
||||
@ -36,45 +41,52 @@ interface OverlayContextType {
|
||||
const OverlayContext = createContext<OverlayContextType | undefined>(undefined);
|
||||
|
||||
// Default values for overlay state
|
||||
const defaultOverlayState: Omit<OverlayState, "id" | "title"> = {
|
||||
const defaultOverlayState: Omit<OverlayState, "id" | "title" | "icon"> = {
|
||||
isOpen: false,
|
||||
isMinimized: false,
|
||||
position: "bottom-right",
|
||||
zIndex: 10,
|
||||
position: "bottom-right", // Default position
|
||||
zIndex: 10, // Starting z-index
|
||||
};
|
||||
|
||||
export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
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(() => {
|
||||
maxZIndexRef.current++;
|
||||
maxZIndexRef.current += 1;
|
||||
return maxZIndexRef.current;
|
||||
}, []);
|
||||
|
||||
// Register a new overlay
|
||||
const registerOverlay = useCallback((id: OverlayId, initialState: Partial<OverlayState>) => {
|
||||
setOverlays((prev) => {
|
||||
if (prev[id]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...defaultOverlayState,
|
||||
id,
|
||||
title: id,
|
||||
...initialState,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const registerOverlay = useCallback(
|
||||
(id: OverlayId, initialState: Partial<Omit<OverlayState, "id" | "zIndex">>) => {
|
||||
setOverlays((prev) => {
|
||||
if (prev[id]) {
|
||||
console.warn(`Overlay with id "${id}" already registered.`);
|
||||
return prev;
|
||||
}
|
||||
const newZIndex = initialState.isOpen ? getNextZIndex() : defaultOverlayState.zIndex;
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...defaultOverlayState,
|
||||
id,
|
||||
title: id, // Default title to id
|
||||
...initialState,
|
||||
zIndex: newZIndex, // Set initial z-index
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[getNextZIndex]
|
||||
);
|
||||
|
||||
// Unregister an overlay
|
||||
const unregisterOverlay = useCallback((id: OverlayId) => {
|
||||
setOverlays((prev) => {
|
||||
const newOverlays = { ...prev };
|
||||
delete newOverlays[id];
|
||||
return newOverlays;
|
||||
const { [id]: _, ...rest } = prev; // Use destructuring to remove the key
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -82,14 +94,14 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
const openOverlay = useCallback(
|
||||
(id: OverlayId) => {
|
||||
setOverlays((prev) => {
|
||||
if (!prev[id]) return prev;
|
||||
if (!prev[id] || prev[id].isOpen) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
isOpen: true,
|
||||
isMinimized: false,
|
||||
zIndex: getNextZIndex(),
|
||||
isMinimized: false, // Ensure not minimized when opened
|
||||
zIndex: getNextZIndex(), // Bring to front
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -100,33 +112,31 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
// Close an overlay
|
||||
const closeOverlay = useCallback((id: OverlayId) => {
|
||||
setOverlays((prev) => {
|
||||
if (!prev[id]) return prev;
|
||||
if (!prev[id] || !prev[id].isOpen) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
isOpen: false,
|
||||
},
|
||||
[id]: { ...prev[id], isOpen: false },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle an overlay
|
||||
// Toggle an overlay's open/closed state
|
||||
const toggleOverlay = useCallback(
|
||||
(id: OverlayId) => {
|
||||
setOverlays((prev) => {
|
||||
if (!prev[id]) return prev;
|
||||
const newState = {
|
||||
...prev[id],
|
||||
isOpen: !prev[id].isOpen,
|
||||
};
|
||||
if (newState.isOpen) {
|
||||
newState.isMinimized = false;
|
||||
newState.zIndex = getNextZIndex();
|
||||
}
|
||||
if (!prev[id]) return prev; // Don't toggle non-existent overlays
|
||||
|
||||
const willBeOpen = !prev[id].isOpen;
|
||||
const newZIndex = willBeOpen ? getNextZIndex() : prev[id].zIndex; // Bring to front only if opening
|
||||
|
||||
return {
|
||||
...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
|
||||
const minimizeOverlay = useCallback((id: OverlayId) => {
|
||||
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 {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
isMinimized: true,
|
||||
// Optionally send to back when minimized: zIndex: defaultOverlayState.zIndex
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -151,13 +162,13 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
const maximizeOverlay = useCallback(
|
||||
(id: OverlayId) => {
|
||||
setOverlays((prev) => {
|
||||
if (!prev[id]) return prev;
|
||||
if (!prev[id] || !prev[id].isOpen || !prev[id].isMinimized) return prev; // Only maximize minimized overlays
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
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;
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
position,
|
||||
},
|
||||
[id]: { ...prev[id], position },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@ -183,13 +191,12 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
const bringToFront = useCallback(
|
||||
(id: OverlayId) => {
|
||||
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 {
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
zIndex: getNextZIndex(),
|
||||
},
|
||||
[id]: { ...prev[id], zIndex: getNextZIndex() },
|
||||
};
|
||||
});
|
||||
},
|
||||
@ -213,6 +220,7 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
||||
return <OverlayContext.Provider value={value}>{children}</OverlayContext.Provider>;
|
||||
}
|
||||
|
||||
// Hook to use the overlay context
|
||||
export function useOverlay() {
|
||||
const context = useContext(OverlayContext);
|
||||
if (context === undefined) {
|
||||
@ -220,4 +228,3 @@ export function useOverlay() {
|
||||
}
|
||||
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() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
export function useIsMobile(): boolean {
|
||||
// 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(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
// Ensure this runs only client-side
|
||||
if (typeof window === "undefined") {
|
||||
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
|
||||
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 {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
const TOAST_LIMIT = 1; // Show only one toast at a time
|
||||
const TOAST_REMOVE_DELAY = 1000000; // A very long time (effectively manual dismiss only)
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
let count = 0
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
// Slice ensures the limit is enforced
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
// Side effect: schedule removal for dismissed toasts
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@ -109,48 +109,48 @@ export const reducer = (state: State, action: Action): State => {
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
open: false, // Trigger the close animation
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
toasts: [], // Remove all toasts
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
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) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
@ -159,36 +159,37 @@ function toast({ ...props }: Toast) {
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
if (!open) dismiss(); // Ensure dismiss is called when the toast closes itself
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
// Clean up listener
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
};
|
||||
}, [state]); // Only re-subscribe if state instance changes (it shouldn't)
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
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[]) {
|
||||
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";
|
||||
|
||||
/** @type {import('next').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;
|
||||
|
||||
@ -61,7 +61,8 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.6",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@ -171,6 +171,9 @@ importers:
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
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:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3
|
||||
@ -2926,6 +2929,24 @@ packages:
|
||||
zod@3.24.2:
|
||||
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:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@ -5939,3 +5960,9 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
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 { fontFamily } from "tailwindcss/defaultTheme"; // Import default theme
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
darkMode: ["class"], // Use class-based dark mode
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{ts,tsx}", // Scan app directory
|
||||
"./components/**/*.{ts,tsx}", // Scan components directory (common and ui)
|
||||
"./features/**/*.{ts,tsx}", // <= NEW: Scan features directory
|
||||
// Remove older paths if no longer relevant
|
||||
// "./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: {
|
||||
container: {
|
||||
center: true,
|
||||
@ -19,7 +21,12 @@ const config = {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
// Add sans-serif font family using CSS variable defined in layout.tsx
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
},
|
||||
colors: {
|
||||
// Keep Shadcn UI color definitions using CSS variables
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
@ -53,15 +60,16 @@ const config = {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
// Sidebar specific colors (ensure these variables are defined in globals.css)
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
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))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--sidebar-accent))",
|
||||
foreground: "hsl(var(--sidebar-accent-foreground))",
|
||||
},
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
@ -78,14 +86,20 @@ const config = {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
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: {
|
||||
"accordion-down": "accordion-down 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;
|
||||
|
||||
export default config;
|
||||
|
||||
@ -1,27 +1,56 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2017", // Keep target
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"strict": true, // Keep strict mode
|
||||
"noEmit": true, // Next.js handles emitting
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext", // Use esnext module system
|
||||
"moduleResolution": "bundler", // Recommended for modern TS/JS
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "preserve", // Let Next.js handle JSX transform
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
// Ensure path aliases cover the new structure
|
||||
"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"],
|
||||
"exclude": ["node_modules"]
|
||||
// Update include paths
|
||||
"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