From 1f89bfbc6d345cc51e80e077f5dbe4d836dde731 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:21:25 +0000 Subject: [PATCH 1/4] Initial plan From 121a70bfe887a261221331743f3af59ae1120934 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:30:43 +0000 Subject: [PATCH 2/4] feat: implement county map popup feature with built-in data - Add CountyMapPopup component with full interactivity - Add showCountyMapOnClick prop to enable county map display - Add countyData prop for providing county information - Add countyMapWrapper prop for custom popup styling - Add onCountyClick callback for county interactions - Include sample county data for Istanbul, Ankara, and Izmir - Export county data separately for tree-shaking support - Add comprehensive test coverage for new features - Update README with county map usage examples Co-authored-by: erdigokce <17235148+erdigokce@users.noreply.github.com> --- readme.md | 83 +++++++++ src/CountyMapPopup.tsx | 238 ++++++++++++++++++++++++++ src/__tests__/CountyMapPopup.test.tsx | 189 ++++++++++++++++++++ src/__tests__/TurkeyMap.test.tsx | 196 +++++++++++++++++++++ src/data/counties/ankara.ts | 133 ++++++++++++++ src/data/counties/index.ts | 10 ++ src/data/counties/istanbul.ts | 203 ++++++++++++++++++++++ src/data/counties/izmir.ts | 153 +++++++++++++++++ src/index.tsx | 63 ++++++- 9 files changed, 1262 insertions(+), 6 deletions(-) create mode 100644 src/CountyMapPopup.tsx create mode 100644 src/__tests__/CountyMapPopup.test.tsx create mode 100644 src/data/counties/ankara.ts create mode 100644 src/data/counties/index.ts create mode 100644 src/data/counties/istanbul.ts create mode 100644 src/data/counties/izmir.ts diff --git a/readme.md b/readme.md index 31bf033..e11c2bf 100644 --- a/readme.md +++ b/readme.md @@ -87,6 +87,83 @@ in other words : { id: string, plateNumber: number, name: string, path: string }[] ``` +### County Map Feature + +When enabled, clicking on a city displays a popup showing the county (ilçe) map of that city. This feature: +- Must be explicitly enabled via `showCountyMapOnClick` prop +- Requires county data to be imported separately to keep bundle size small when not used +- Can be customized via `countyMapWrapper` prop +- Supports all the same interaction handlers as the main map + +#### Basic Usage + +```javascript +import TurkeyMap from 'turkey-map-react'; +import { istanbulCounties, ankaraCounties } from 'turkey-map-react/lib/data/counties'; + +// Import only the county data you need +const countyData = { + istanbul: istanbulCounties, + ankara: ankaraCounties +}; + + +``` + +#### Handling County Events + +```javascript + { + console.log(`${county.name} in ${city.name} was clicked!`); + }} +/> +``` + +#### Custom County Map Wrapper + +```javascript +const customCountyMapWrapper = (countyMapPopup, city, countyData) => ( +
+

Custom Header for {city.name}

+ {countyMapPopup} +
+); + + +``` + +#### Providing Custom County Data + +County data must follow the `CountyData` type structure: + +```javascript +import { CountyData } from 'turkey-map-react'; + +const myCustomCountyData: CountyData = { + cityId: "mycity", + cityName: "My City", + counties: [ + { id: "county1", name: "County 1", path: "M 0 0 L 100 0..." }, + { id: "county2", name: "County 2", path: "M 100 0 L 200 0..." } + ] +}; + + +``` + ## API ### Types @@ -94,6 +171,8 @@ in other words : | Type | Description | | :---------------- | :-------------------------------------------------------------------------------------- | | *CityType* | { **id**: *string*, **plateNumber**: *number*, **name**: *string*, **path**: *string* } | +| *CountyType* | { **id**: *string*, **name**: *string*, **path**: *string* } | +| *CountyData* | { **cityId**: *string*, **cityName**: *string*, **counties**: *CountyType*[] } | | *ViewBoxType* | { **top**: *number*, **left**: *number*, **width**: *number*, **height**: *number* } | | *CustomStyleType* | { **idleColor**: *string*, **hoverColor**: *string* } | @@ -111,6 +190,10 @@ in other words : | cityWrapper | City DOMs are wrapped by provided component. | ( **cityComponent**: *JSX.Element*, **city** : *CityType* ) => *JSX.Element* | *Unwrapped city* | 1.0.0 | | onHover | Event when a city hovered on the map. | ( **city** : *CityType* ) => *void* | - | 1.0.0 | | onClick | Event when a city clicked on the map | ( **city** : *CityType* ) => *void* | - | 1.0.0 | +| showCountyMapOnClick | Enables county map popup when a city is clicked | *boolean* | *false* | 3.0.0 | +| countyData | County data for cities (import only what you need) | *Record* | *undefined* | 3.0.0 | +| countyMapWrapper | Custom wrapper for county map popup | ( **popup**: *JSX.Element*, **city**: *CityType*, **data**: *CountyData* ) => *JSX.Element* | *Default popup* | 3.0.0 | +| onCountyClick | Event when a county is clicked in the county map | ( **county**: *CountyType*, **city**: *CityType* ) => *void* | - | 3.0.0 | ### Styling diff --git a/src/CountyMapPopup.tsx b/src/CountyMapPopup.tsx new file mode 100644 index 0000000..6bedcb2 --- /dev/null +++ b/src/CountyMapPopup.tsx @@ -0,0 +1,238 @@ +import React, { MouseEventHandler, useCallback, useState } from 'react'; +import { Property } from 'csstype'; + +export type CountyType = { + id: string; + name: string; + path: string; +}; + +export type CountyData = { + cityId: string; + cityName: string; + counties: CountyType[]; +}; + +interface ICountyMapPopupProps { + countyData: CountyData; + onClose: () => void; + customStyle?: { idleColor: string, hoverColor: string }; + hoverable?: boolean; + showTooltip?: boolean; + tooltipText?: string; + onCountyHover?: (county: CountyType) => void; + onCountyClick?: (county: CountyType) => void; +} + +const CountyMapPopup: React.FC = ({ + countyData, + onClose, + customStyle = { idleColor: "#444", hoverColor: "#dc3522" }, + hoverable = true, + showTooltip = false, + tooltipText, + onCountyHover, + onCountyClick +}) => { + const [hoveredCountyName, setHoveredCountyName] = useState(undefined); + const [tooltipStyle, setTooltipStyle] = useState<{ + left: number, + top: number, + visibility?: Property.Visibility, + animation?: Property.Animation + }>({ left: 0, top: 0, visibility: "hidden" }); + + const handleMouseEvent = useCallback(( + event: React.MouseEvent, + callback: (county: CountyType) => void + ) => { + const element = event.target as Element; + + if (element.tagName === 'path') { + const parent = element.parentNode as Element; + + const countyId = parent.getAttribute('id') ?? ""; + const countyPath = element.getAttribute("d") ?? ""; + const countyName: string = parent.getAttribute('data-ilceadi') ?? ""; + const county: CountyType = { id: countyId, name: countyName, path: countyPath }; + + if (callback && typeof callback === 'function') { + callback(county); + } + } + }, []); + + const handleHover = useCallback((county: CountyType) => { + setHoveredCountyName(county.name); + if (onCountyHover) { + onCountyHover(county); + } + }, [onCountyHover]); + + const handleOnHover = useCallback((event: React.MouseEvent): void => { + handleMouseEvent(event, handleHover); + }, [handleMouseEvent, handleHover]); + + const handleOnClick = useCallback((event: React.MouseEvent): void => { + if (onCountyClick) { + handleMouseEvent(event, onCountyClick); + } + }, [onCountyClick, handleMouseEvent]); + + const handleOnMouseMove: MouseEventHandler = useCallback((event) => { + setTooltipStyle(prevState => ({ + ...prevState, + left: event.pageX + 16, + top: event.pageY - 32 + })); + }, []); + + const handleOnMouseEnter = useCallback((event: React.MouseEvent): void => { + const target = event.currentTarget; + const path = target.querySelector('path'); + if (path) { + path.style.fill = customStyle.hoverColor; + } + if (!showTooltip) return; + + setTooltipStyle(prevState => ({ + ...prevState, + animation: undefined, + visibility: "visible" + })); + }, [customStyle.hoverColor, showTooltip]); + + const handleOnMouseLeave = useCallback((event: React.MouseEvent): void => { + const target = event.currentTarget; + const path = target.querySelector('path'); + if (path) { + path.style.fill = customStyle.idleColor; + } + if (!showTooltip) return; + + setTooltipStyle(prevState => ({ + ...prevState, + visibility: undefined, + animation: `0.1s county_react_map_tooltip_fade_out forwards ease-out`, + })); + }, [customStyle.idleColor, showTooltip]); + + const handleOverlayClick = useCallback((event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }, [onClose]); + + return ( +
+
+
+

{countyData.cityName} - İlçeler

+ +
+ +
+ {showTooltip && ( +
+ {tooltipText || hoveredCountyName} +
+ )} + + + + {countyData.counties.map((county) => ( + hoverable ? handleOnHover(event) : undefined} + onMouseMove={showTooltip ? handleOnMouseMove : undefined} + onClick={handleOnClick} + > + + + ))} + + + + {showTooltip && } +
+
+
+ ); +}; + +export default CountyMapPopup; diff --git a/src/__tests__/CountyMapPopup.test.tsx b/src/__tests__/CountyMapPopup.test.tsx new file mode 100644 index 0000000..e22462e --- /dev/null +++ b/src/__tests__/CountyMapPopup.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CountyMapPopup, { CountyData } from '../CountyMapPopup'; + +describe('CountyMapPopup Component', () => { + const mockCountyData: CountyData = { + cityId: "test-city", + cityName: "Test City", + counties: [ + { id: "county1", name: "County 1", path: "M 0 0 L 100 0 L 100 100 L 0 100 Z" }, + { id: "county2", name: "County 2", path: "M 100 0 L 200 0 L 200 100 L 100 100 Z" } + ] + }; + + const mockOnClose = jest.fn(); + + beforeEach(() => { + mockOnClose.mockClear(); + }); + + describe('Basic Rendering', () => { + it('should render without crashing', () => { + const { container } = render( + + ); + + expect(container.querySelector('#county-map-container')).toBeInTheDocument(); + }); + + it('should display city name in header', () => { + render(); + + expect(screen.getByText(/Test City - İlçeler/i)).toBeInTheDocument(); + }); + + it('should render all counties', () => { + const { container } = render( + + ); + + const countyGroups = container.querySelectorAll('g[data-ilceadi]'); + expect(countyGroups.length).toBe(2); + }); + + it('should have close button', () => { + render(); + + const closeButton = screen.getByLabelText('Close'); + expect(closeButton).toBeInTheDocument(); + }); + }); + + describe('Interaction', () => { + it('should call onClose when close button is clicked', () => { + render(); + + const closeButton = screen.getByLabelText('Close'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when overlay is clicked', () => { + const { container } = render( + + ); + + const overlay = container.firstChild as HTMLElement; + fireEvent.click(overlay); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should not call onClose when popup content is clicked', () => { + const { container } = render( + + ); + + const popupContent = container.querySelector('#county-map-container') as HTMLElement; + fireEvent.click(popupContent); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should call onCountyClick when a county is clicked', () => { + const onCountyClick = jest.fn(); + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + expect(onCountyClick).toHaveBeenCalled(); + const countyArg = onCountyClick.mock.calls[0][0]; + expect(countyArg).toHaveProperty('id'); + expect(countyArg).toHaveProperty('name'); + expect(countyArg).toHaveProperty('path'); + } + }); + + it('should call onCountyHover when hovering over a county', () => { + const onCountyHover = jest.fn(); + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath && firstPath.parentElement) { + fireEvent.mouseOver(firstPath); + expect(onCountyHover).toHaveBeenCalled(); + } + }); + }); + + describe('Styling', () => { + it('should apply custom idle color', () => { + const customStyle = { idleColor: '#ff0000', hoverColor: '#00ff00' }; + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + expect(firstPath).toHaveStyle({ fill: '#ff0000' }); + }); + + it('should change fill color on mouse enter', () => { + const customStyle = { idleColor: '#ff0000', hoverColor: '#00ff00' }; + const { container } = render( + + ); + + const firstCountyGroup = container.querySelector('g[data-ilceadi]'); + const firstPath = container.querySelector('path') as SVGPathElement; + + if (firstCountyGroup && firstPath) { + expect(firstPath.style.fill).toBe('#ff0000'); + fireEvent.mouseEnter(firstCountyGroup); + expect(firstPath.style.fill).toBe('#00ff00'); + } + }); + }); + + describe('Tooltip Functionality', () => { + it('should render tooltip when showTooltip is true', () => { + const { container } = render( + + ); + + const tooltip = container.querySelector('#county-map-tooltip'); + expect(tooltip).toBeInTheDocument(); + }); + + it('should not render tooltip when showTooltip is false', () => { + const { container } = render( + + ); + + const tooltip = container.querySelector('#county-map-tooltip'); + expect(tooltip).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/TurkeyMap.test.tsx b/src/__tests__/TurkeyMap.test.tsx index e0e1b6b..0e26a91 100644 --- a/src/__tests__/TurkeyMap.test.tsx +++ b/src/__tests__/TurkeyMap.test.tsx @@ -212,4 +212,200 @@ describe('TurkeyMap Component', () => { expect(initialCityCount).toBe(afterRerenderCount); }); }); + + describe('County Map Functionality', () => { + const mockCountyData = { + istanbul: { + cityId: "istanbul", + cityName: "İstanbul", + counties: [ + { id: "kadikoy", name: "Kadıköy", path: "M 0 0 L 100 0 L 100 100 L 0 100 Z" }, + { id: "besiktas", name: "Beşiktaş", path: "M 100 0 L 200 0 L 200 100 L 100 100 Z" } + ] + } + }; + + it('should not show county map by default', () => { + const { container } = render(); + const countyMap = container.querySelector('#county-map-container'); + expect(countyMap).not.toBeInTheDocument(); + }); + + it('should not show county map when showCountyMapOnClick is false', () => { + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + const countyMap = container.querySelector('#county-map-container'); + expect(countyMap).not.toBeInTheDocument(); + } + }); + + it('should show county map when city is clicked and showCountyMapOnClick is true', () => { + const customData = { + cities: [ + { id: 'istanbul', plateNumber: 34, name: 'İstanbul', path: 'M 0 0 L 100 100' } + ] + }; + + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + const countyMap = container.querySelector('#county-map-container'); + expect(countyMap).toBeInTheDocument(); + } + }); + + it('should not show county map if county data is not available for the city', () => { + const customData = { + cities: [ + { id: 'ankara', plateNumber: 6, name: 'Ankara', path: 'M 0 0 L 100 100' } + ] + }; + + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + const countyMap = container.querySelector('#county-map-container'); + expect(countyMap).not.toBeInTheDocument(); + } + }); + + it('should close county map when close button is clicked', () => { + const customData = { + cities: [ + { id: 'istanbul', plateNumber: 34, name: 'İstanbul', path: 'M 0 0 L 100 100' } + ] + }; + + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + + const closeButton = screen.getByLabelText('Close'); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + const countyMap = container.querySelector('#county-map-container'); + expect(countyMap).not.toBeInTheDocument(); + } + }); + + it('should call onClick handler even when showCountyMapOnClick is true', () => { + const onClickMock = jest.fn(); + const customData = { + cities: [ + { id: 'istanbul', plateNumber: 34, name: 'İstanbul', path: 'M 0 0 L 100 100' } + ] + }; + + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + expect(onClickMock).toHaveBeenCalled(); + } + }); + + it('should call onCountyClick when a county is clicked', () => { + const onCountyClick = jest.fn(); + const customData = { + cities: [ + { id: 'istanbul', plateNumber: 34, name: 'İstanbul', path: 'M 0 0 L 100 100' } + ] + }; + + const { container } = render( + + ); + + // Open county map + const cityPath = container.querySelector('path'); + if (cityPath) { + fireEvent.click(cityPath); + + // Click on a county + const countyPath = container.querySelectorAll('path')[1]; // Get county path + if (countyPath) { + fireEvent.click(countyPath); + expect(onCountyClick).toHaveBeenCalled(); + } + } + }); + + it('should apply custom wrapper to county map', () => { + const customData = { + cities: [ + { id: 'istanbul', plateNumber: 34, name: 'İstanbul', path: 'M 0 0 L 100 100' } + ] + }; + + const countyMapWrapper = (countyMapPopup: React.ReactElement) => { + return ( +
+ {countyMapPopup} +
+ ); + }; + + const { container } = render( + + ); + + const firstPath = container.querySelector('path'); + if (firstPath) { + fireEvent.click(firstPath); + const customCountyMap = container.querySelector('[data-testid="custom-county-map"]'); + expect(customCountyMap).toBeInTheDocument(); + } + }); + }); }); diff --git a/src/data/counties/ankara.ts b/src/data/counties/ankara.ts new file mode 100644 index 0000000..b213bf8 --- /dev/null +++ b/src/data/counties/ankara.ts @@ -0,0 +1,133 @@ +import { CountyData } from '../../CountyMapPopup'; + +export const ankaraCounties: CountyData = { + cityId: "ankara", + cityName: "Ankara", + counties: [ + { + id: "akyurt", + name: "Akyurt", + path: "M 700,400 L 780,400 L 780,480 L 700,480 Z" + }, + { + id: "altindag", + name: "Altındağ", + path: "M 480,380 L 560,380 L 560,460 L 480,460 Z" + }, + { + id: "ayas", + name: "Ayaş", + path: "M 150,350 L 250,350 L 250,450 L 150,450 Z" + }, + { + id: "bala", + name: "Bala", + path: "M 700,500 L 800,500 L 800,600 L 700,600 Z" + }, + { + id: "beypazari", + name: "Beypazarı", + path: "M 200,250 L 300,250 L 300,350 L 200,350 Z" + }, + { + id: "camlidere", + name: "Çamlıdere", + path: "M 250,150 L 350,150 L 350,250 L 250,250 Z" + }, + { + id: "cankaya", + name: "Çankaya", + path: "M 500,450 L 580,450 L 580,530 L 500,530 Z" + }, + { + id: "cubuk", + name: "Çubuk", + path: "M 550,250 L 650,250 L 650,350 L 550,350 Z" + }, + { + id: "elmadag", + name: "Elmadağ", + path: "M 650,300 L 730,300 L 730,380 L 650,380 Z" + }, + { + id: "etimesgut", + name: "Etimesgut", + path: "M 380,400 L 460,400 L 460,480 L 380,480 Z" + }, + { + id: "evren", + name: "Evren", + path: "M 600,600 L 680,600 L 680,680 L 600,680 Z" + }, + { + id: "golbasi", + name: "Gölbaşı", + path: "M 550,550 L 640,550 L 640,640 L 550,640 Z" + }, + { + id: "gudul", + name: "Güdül", + path: "M 300,300 L 380,300 L 380,380 L 300,380 Z" + }, + { + id: "haymana", + name: "Haymana", + path: "M 400,600 L 500,600 L 500,700 L 400,700 Z" + }, + { + id: "kahramankazan", + name: "Kahramankazan", + path: "M 400,300 L 480,300 L 480,380 L 400,380 Z" + }, + { + id: "kalecik", + name: "Kalecik", + path: "M 700,250 L 780,250 L 780,330 L 700,330 Z" + }, + { + id: "kecioren", + name: "Keçiören", + path: "M 480,320 L 560,320 L 560,400 L 480,400 Z" + }, + { + id: "kizilcahamam", + name: "Kızılcahamam", + path: "M 400,150 L 500,150 L 500,250 L 400,250 Z" + }, + { + id: "mamak", + name: "Mamak", + path: "M 560,400 L 640,400 L 640,480 L 560,480 Z" + }, + { + id: "nallihan", + name: "Nallıhan", + path: "M 150,200 L 250,200 L 250,300 L 150,300 Z" + }, + { + id: "polatli", + name: "Polatlı", + path: "M 200,500 L 320,500 L 320,620 L 200,620 Z" + }, + { + id: "pursaklar", + name: "Pursaklar", + path: "M 450,350 L 520,350 L 520,420 L 450,420 Z" + }, + { + id: "sincan", + name: "Sincan", + path: "M 320,400 L 400,400 L 400,480 L 320,480 Z" + }, + { + id: "sereflikochisar", + name: "Şereflikoçhisar", + path: "M 500,650 L 600,650 L 600,750 L 500,750 Z" + }, + { + id: "yenimahalle", + name: "Yenimahalle", + path: "M 420,350 L 500,350 L 500,430 L 420,430 Z" + } + ] +}; diff --git a/src/data/counties/index.ts b/src/data/counties/index.ts new file mode 100644 index 0000000..d1792f6 --- /dev/null +++ b/src/data/counties/index.ts @@ -0,0 +1,10 @@ +// County data exports for individual cities +// Import only the county data you need to keep bundle size small +// Example: import { istanbulCounties } from 'turkey-map-react/lib/data/counties'; + +export { istanbulCounties } from './istanbul'; +export { ankaraCounties } from './ankara'; +export { izmirCounties } from './izmir'; + +// Re-export CountyData type for convenience +export type { CountyData, CountyType } from '../../CountyMapPopup'; diff --git a/src/data/counties/istanbul.ts b/src/data/counties/istanbul.ts new file mode 100644 index 0000000..1137c73 --- /dev/null +++ b/src/data/counties/istanbul.ts @@ -0,0 +1,203 @@ +import { CountyData } from '../../CountyMapPopup'; + +export const istanbulCounties: CountyData = { + cityId: "istanbul", + cityName: "İstanbul", + counties: [ + { + id: "adalar", + name: "Adalar", + path: "M 850,450 L 900,450 L 900,500 L 850,500 Z" + }, + { + id: "arnavutkoy", + name: "Arnavutköy", + path: "M 200,150 L 280,150 L 280,220 L 200,220 Z" + }, + { + id: "atasehir", + name: "Ataşehir", + path: "M 650,400 L 720,400 L 720,460 L 650,460 Z" + }, + { + id: "avcilar", + name: "Avcılar", + path: "M 350,450 L 420,450 L 420,520 L 350,520 Z" + }, + { + id: "bagcilar", + name: "Bağcılar", + path: "M 420,400 L 490,400 L 490,460 L 420,460 Z" + }, + { + id: "bahcelievler", + name: "Bahçelievler", + path: "M 450,450 L 520,450 L 520,520 L 450,520 Z" + }, + { + id: "bakirkoy", + name: "Bakırköy", + path: "M 400,500 L 470,500 L 470,570 L 400,570 Z" + }, + { + id: "basaksehir", + name: "Başakşehir", + path: "M 350,350 L 430,350 L 430,420 L 350,420 Z" + }, + { + id: "bayrampasa", + name: "Bayrampaşa", + path: "M 470,400 L 530,400 L 530,450 L 470,450 Z" + }, + { + id: "besiktas", + name: "Beşiktaş", + path: "M 520,300 L 580,300 L 580,360 L 520,360 Z" + }, + { + id: "beykoz", + name: "Beykoz", + path: "M 650,200 L 750,200 L 750,300 L 650,300 Z" + }, + { + id: "beylikduzu", + name: "Beylikdüzü", + path: "M 280,500 L 360,500 L 360,570 L 280,570 Z" + }, + { + id: "beyoglu", + name: "Beyoğlu", + path: "M 540,320 L 600,320 L 600,380 L 540,380 Z" + }, + { + id: "buyukcekmece", + name: "Büyükçekmece", + path: "M 200,450 L 290,450 L 290,540 L 200,540 Z" + }, + { + id: "catalca", + name: "Çatalca", + path: "M 100,250 L 210,250 L 210,360 L 100,360 Z" + }, + { + id: "cekmekoy", + name: "Çekmeköy", + path: "M 700,300 L 770,300 L 770,370 L 700,370 Z" + }, + { + id: "esenler", + name: "Esenler", + path: "M 440,420 L 500,420 L 500,470 L 440,470 Z" + }, + { + id: "esenyurt", + name: "Esenyurt", + path: "M 300,400 L 380,400 L 380,480 L 300,480 Z" + }, + { + id: "eyupsultan", + name: "Eyüpsultan", + path: "M 480,280 L 560,280 L 560,350 L 480,350 Z" + }, + { + id: "fatih", + name: "Fatih", + path: "M 520,360 L 580,360 L 580,420 L 520,420 Z" + }, + { + id: "gaziosmanpasa", + name: "Gaziosmanpaşa", + path: "M 450,320 L 520,320 L 520,380 L 450,380 Z" + }, + { + id: "gungoren", + name: "Güngören", + path: "M 430,450 L 480,450 L 480,500 L 430,500 Z" + }, + { + id: "kadikoy", + name: "Kadıköy", + path: "M 620,420 L 690,420 L 690,490 L 620,490 Z" + }, + { + id: "kagithane", + name: "Kâğıthane", + path: "M 500,320 L 560,320 L 560,370 L 500,370 Z" + }, + { + id: "kartal", + name: "Kartal", + path: "M 700,480 L 780,480 L 780,560 L 700,560 Z" + }, + { + id: "kucukcekmece", + name: "Küçükçekmece", + path: "M 360,450 L 440,450 L 440,530 L 360,530 Z" + }, + { + id: "maltepe", + name: "Maltepe", + path: "M 680,450 L 750,450 L 750,520 L 680,520 Z" + }, + { + id: "pendik", + name: "Pendik", + path: "M 750,500 L 830,500 L 830,580 L 750,580 Z" + }, + { + id: "sancaktepe", + name: "Sancaktepe", + path: "M 730,370 L 800,370 L 800,440 L 730,440 Z" + }, + { + id: "sariyer", + name: "Sarıyer", + path: "M 500,180 L 600,180 L 600,280 L 500,280 Z" + }, + { + id: "silivri", + name: "Silivri", + path: "M 100,450 L 210,450 L 210,560 L 100,560 Z" + }, + { + id: "sultanbeyli", + name: "Sultanbeyli", + path: "M 780,400 L 850,400 L 850,470 L 780,470 Z" + }, + { + id: "sultangazi", + name: "Sultangazi", + path: "M 420,320 L 490,320 L 490,380 L 420,380 Z" + }, + { + id: "sile", + name: "Şile", + path: "M 800,150 L 900,150 L 900,250 L 800,250 Z" + }, + { + id: "sisli", + name: "Şişli", + path: "M 530,330 L 590,330 L 590,390 L 530,390 Z" + }, + { + id: "tuzla", + name: "Tuzla", + path: "M 800,500 L 880,500 L 880,580 L 800,580 Z" + }, + { + id: "umraniye", + name: "Ümraniye", + path: "M 680,360 L 750,360 L 750,430 L 680,430 Z" + }, + { + id: "uskudar", + name: "Üsküdar", + path: "M 600,380 L 670,380 L 670,450 L 600,450 Z" + }, + { + id: "zeytinburnu", + name: "Zeytinburnu", + path: "M 480,450 L 540,450 L 540,510 L 480,510 Z" + } + ] +}; diff --git a/src/data/counties/izmir.ts b/src/data/counties/izmir.ts new file mode 100644 index 0000000..bca8ef7 --- /dev/null +++ b/src/data/counties/izmir.ts @@ -0,0 +1,153 @@ +import { CountyData } from '../../CountyMapPopup'; + +export const izmirCounties: CountyData = { + cityId: "izmir", + cityName: "İzmir", + counties: [ + { + id: "aliaga", + name: "Aliağa", + path: "M 300,150 L 380,150 L 380,230 L 300,230 Z" + }, + { + id: "balcova", + name: "Balçova", + path: "M 380,480 L 450,480 L 450,550 L 380,550 Z" + }, + { + id: "bayindir", + name: "Bayındır", + path: "M 650,550 L 730,550 L 730,630 L 650,630 Z" + }, + { + id: "bayrakli", + name: "Bayraklı", + path: "M 420,380 L 490,380 L 490,450 L 420,450 Z" + }, + { + id: "bergama", + name: "Bergama", + path: "M 350,100 L 470,100 L 470,220 L 350,220 Z" + }, + { + id: "bornova", + name: "Bornova", + path: "M 480,420 L 560,420 L 560,500 L 480,500 Z" + }, + { + id: "buca", + name: "Buca", + path: "M 520,480 L 600,480 L 600,560 L 520,560 Z" + }, + { + id: "cesme", + name: "Çeşme", + path: "M 100,500 L 180,500 L 180,580 L 100,580 Z" + }, + { + id: "cigli", + name: "Çiğli", + path: "M 380,320 L 460,320 L 460,400 L 380,400 Z" + }, + { + id: "dikili", + name: "Dikili", + path: "M 250,200 L 330,200 L 330,280 L 250,280 Z" + }, + { + id: "foca", + name: "Foça", + path: "M 300,250 L 380,250 L 380,330 L 300,330 Z" + }, + { + id: "gaziemir", + name: "Gaziemir", + path: "M 450,520 L 520,520 L 520,590 L 450,590 Z" + }, + { + id: "guzelbahce", + name: "Güzelbahçe", + path: "M 320,520 L 390,520 L 390,590 L 320,590 Z" + }, + { + id: "karabaglar", + name: "Karabağlar", + path: "M 440,460 L 510,460 L 510,530 L 440,530 Z" + }, + { + id: "karaburun", + name: "Karaburun", + path: "M 150,400 L 240,400 L 240,490 L 150,490 Z" + }, + { + id: "karsiyaka", + name: "Karşıyaka", + path: "M 400,360 L 470,360 L 470,430 L 400,430 Z" + }, + { + id: "kemalpasa", + name: "Kemalpaşa", + path: "M 550,350 L 640,350 L 640,440 L 550,440 Z" + }, + { + id: "kinik", + name: "Kınık", + path: "M 400,200 L 480,200 L 480,280 L 400,280 Z" + }, + { + id: "kiraz", + name: "Kiraz", + path: "M 700,450 L 780,450 L 780,530 L 700,530 Z" + }, + { + id: "konak", + name: "Konak", + path: "M 410,450 L 480,450 L 480,520 L 410,520 Z" + }, + { + id: "menderes", + name: "Menderes", + path: "M 580,550 L 660,550 L 660,630 L 580,630 Z" + }, + { + id: "menemen", + name: "Menemen", + path: "M 380,280 L 470,280 L 470,370 L 380,370 Z" + }, + { + id: "narlidere", + name: "Narlıdere", + path: "M 360,500 L 420,500 L 420,560 L 360,560 Z" + }, + { + id: "odemis", + name: "Ödemiş", + path: "M 650,480 L 750,480 L 750,580 L 650,580 Z" + }, + { + id: "seferihisar", + name: "Seferihisar", + path: "M 480,580 L 560,580 L 560,660 L 480,660 Z" + }, + { + id: "selcuk", + name: "Selçuk", + path: "M 550,550 L 620,550 L 620,620 L 550,620 Z" + }, + { + id: "tire", + name: "Tire", + path: "M 600,600 L 680,600 L 680,680 L 600,680 Z" + }, + { + id: "torbali", + name: "Torbalı", + path: "M 580,500 L 660,500 L 660,580 L 580,580 Z" + }, + { + id: "urla", + name: "Urla", + path: "M 280,480 L 360,480 L 360,560 L 280,560 Z" + } + ] +}; diff --git a/src/index.tsx b/src/index.tsx index d473796..aa42bff 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, MouseEventHandler } from 'react'; import Tooltip from './Tooltip'; +import CountyMapPopup, { CountyData } from './CountyMapPopup'; import { Property } from 'csstype'; import { cities } from './data'; @@ -17,7 +18,11 @@ interface IProps { data?: Data, cityWrapper?: (cityComponent: React.ReactElement, city: CityType) => React.ReactElement, onHover?: (city: CityType) => void, - onClick?: (city: CityType) => void + onClick?: (city: CityType) => void, + showCountyMapOnClick?: boolean, + countyData?: Record, + countyMapWrapper?: (countyMapPopup: React.ReactElement, city: CityType, countyData: CountyData) => React.ReactElement, + onCountyClick?: (county: { id: string; name: string; path: string }, city: CityType) => void } export type CityType = { id: string; plateNumber: number; name: string; path: string }; @@ -25,6 +30,9 @@ export type CustomStyleType = { idleColor: string, hoverColor: string }; export type ViewBoxType = { top: number; left: number; width: number; height: number }; type GetCitiesReturn = { element: React.ReactElement, cityType: CityType }; +// Re-export CountyData and CountyType for convenience +export type { CountyData, CountyType } from './CountyMapPopup'; + const TurkeyMap: React.FC = ({ viewBox = { top: 0, left: 80, width: 1050, height: 585 }, visible = true, @@ -35,9 +43,14 @@ const TurkeyMap: React.FC = ({ tooltipText, cityWrapper, onHover, - onClick + onClick, + showCountyMapOnClick = false, + countyData, + countyMapWrapper, + onCountyClick }) => { const [hoveredCityName, setHoveredCityName] = useState(undefined); + const [selectedCountyData, setSelectedCountyData] = useState<{ city: CityType, countyData: CountyData } | null>(null); const [tooltipStyle, setTooltipStyle] = useState<{ left: number, top: number, @@ -79,10 +92,20 @@ const TurkeyMap: React.FC = ({ }, [handleMouseEvent, handleHover]); const handleOnClick = useCallback((event: React.MouseEvent): void => { - if (onClick) { - handleMouseEvent(event, onClick); - } - }, [onClick, handleMouseEvent]); + const handleCityClick = (city: CityType) => { + // Call the original onClick handler if provided + if (onClick) { + onClick(city); + } + + // Show county map if enabled and county data is available + if (showCountyMapOnClick && countyData && countyData[city.id]) { + setSelectedCountyData({ city, countyData: countyData[city.id] }); + } + }; + + handleMouseEvent(event, handleCityClick); + }, [onClick, handleMouseEvent, showCountyMapOnClick, countyData]); const handleOnMouseMove: MouseEventHandler = useCallback((event) => { setTooltipStyle(prevState => ({ @@ -150,6 +173,16 @@ const TurkeyMap: React.FC = ({ }); }, [getCities, cityWrapper]); + const handleCloseCountyMap = useCallback(() => { + setSelectedCountyData(null); + }, []); + + const handleCountyClickInternal = useCallback((county: { id: string; name: string; path: string }) => { + if (onCountyClick && selectedCountyData) { + onCountyClick(county, selectedCountyData.city); + } + }, [onCountyClick, selectedCountyData]); + const { top, left, width, height } = viewBox; return ( @@ -179,6 +212,24 @@ const TurkeyMap: React.FC = ({ } }`} } + + {/* County Map Popup */} + {selectedCountyData && (() => { + const countyMapPopup = ( + + ); + + return countyMapWrapper + ? countyMapWrapper(countyMapPopup, selectedCountyData.city, selectedCountyData.countyData) + : countyMapPopup; + })()} ); }; From 6677ec6fcd428a340378f9fdfd9867458bf3382d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:34:40 +0000 Subject: [PATCH 3/4] fix: address code review feedback - Improve accessibility with ARIA attributes and ESC key support - Add consistent CountyType usage in API - Document placeholder nature of sample county data - Add test for ESC key functionality - Add test for accessibility attributes Co-authored-by: erdigokce <17235148+erdigokce@users.noreply.github.com> --- src/CountyMapPopup.tsx | 21 +++++++++++++++++++-- src/__tests__/CountyMapPopup.test.tsx | 26 ++++++++++++++++++++++++-- src/__tests__/TurkeyMap.test.tsx | 2 +- src/data/counties/ankara.ts | 6 ++++++ src/data/counties/istanbul.ts | 14 ++++++++++++++ src/data/counties/izmir.ts | 6 ++++++ src/index.tsx | 6 +++--- 7 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/CountyMapPopup.tsx b/src/CountyMapPopup.tsx index 6bedcb2..e7fa7b3 100644 --- a/src/CountyMapPopup.tsx +++ b/src/CountyMapPopup.tsx @@ -42,6 +42,20 @@ const CountyMapPopup: React.FC = ({ animation?: Property.Animation }>({ left: 0, top: 0, visibility: "hidden" }); + // Handle ESC key to close popup + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + const handleMouseEvent = useCallback(( event: React.MouseEvent, callback: (county: CountyType) => void @@ -125,6 +139,9 @@ const CountyMapPopup: React.FC = ({ return (
= ({ alignItems: 'center', marginBottom: '10px' }}> -

{countyData.cityName} - İlçeler

+

{countyData.cityName} - İlçeler

diff --git a/src/__tests__/CountyMapPopup.test.tsx b/src/__tests__/CountyMapPopup.test.tsx index e22462e..15d4d08 100644 --- a/src/__tests__/CountyMapPopup.test.tsx +++ b/src/__tests__/CountyMapPopup.test.tsx @@ -46,21 +46,43 @@ describe('CountyMapPopup Component', () => { it('should have close button', () => { render(); - const closeButton = screen.getByLabelText('Close'); + const closeButton = screen.getByLabelText('Close county map'); expect(closeButton).toBeInTheDocument(); }); + + it('should have proper accessibility attributes', () => { + const { container } = render( + + ); + + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'county-map-title'); + + const title = container.querySelector('#county-map-title'); + expect(title).toBeInTheDocument(); + }); }); describe('Interaction', () => { it('should call onClose when close button is clicked', () => { render(); - const closeButton = screen.getByLabelText('Close'); + const closeButton = screen.getByLabelText('Close county map'); fireEvent.click(closeButton); expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('should call onClose when ESC key is pressed', () => { + render(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + it('should call onClose when overlay is clicked', () => { const { container } = render( diff --git a/src/__tests__/TurkeyMap.test.tsx b/src/__tests__/TurkeyMap.test.tsx index 0e26a91..f312f30 100644 --- a/src/__tests__/TurkeyMap.test.tsx +++ b/src/__tests__/TurkeyMap.test.tsx @@ -312,7 +312,7 @@ describe('TurkeyMap Component', () => { if (firstPath) { fireEvent.click(firstPath); - const closeButton = screen.getByLabelText('Close'); + const closeButton = screen.getByLabelText('Close county map'); expect(closeButton).toBeInTheDocument(); fireEvent.click(closeButton); diff --git a/src/data/counties/ankara.ts b/src/data/counties/ankara.ts index b213bf8..ec575e7 100644 --- a/src/data/counties/ankara.ts +++ b/src/data/counties/ankara.ts @@ -1,5 +1,11 @@ import { CountyData } from '../../CountyMapPopup'; +/** + * Sample county data for Ankara + * + * NOTE: The SVG path data uses placeholder rectangles for demonstration. + * For production, replace with actual geographic county boundaries. + */ export const ankaraCounties: CountyData = { cityId: "ankara", cityName: "Ankara", diff --git a/src/data/counties/istanbul.ts b/src/data/counties/istanbul.ts index 1137c73..b2d3cc3 100644 --- a/src/data/counties/istanbul.ts +++ b/src/data/counties/istanbul.ts @@ -1,5 +1,19 @@ import { CountyData } from '../../CountyMapPopup'; +/** + * Sample county data for Istanbul + * + * NOTE: The SVG path data in this file uses placeholder rectangles for demonstration purposes. + * These are NOT actual geographic boundaries of Istanbul's counties. + * + * For production use, you should: + * 1. Replace these paths with actual geographic SVG data + * 2. Use real county boundary coordinates + * 3. Consider using a geographic data source like GADM or OpenStreetMap + * + * The current rectangular paths are provided as examples to show the data structure + * and to allow testing of the county map functionality. + */ export const istanbulCounties: CountyData = { cityId: "istanbul", cityName: "İstanbul", diff --git a/src/data/counties/izmir.ts b/src/data/counties/izmir.ts index bca8ef7..35290a5 100644 --- a/src/data/counties/izmir.ts +++ b/src/data/counties/izmir.ts @@ -1,5 +1,11 @@ import { CountyData } from '../../CountyMapPopup'; +/** + * Sample county data for Izmir + * + * NOTE: The SVG path data uses placeholder rectangles for demonstration. + * For production, replace with actual geographic county boundaries. + */ export const izmirCounties: CountyData = { cityId: "izmir", cityName: "İzmir", diff --git a/src/index.tsx b/src/index.tsx index aa42bff..8b5ea75 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, MouseEventHandler } from 'react'; import Tooltip from './Tooltip'; -import CountyMapPopup, { CountyData } from './CountyMapPopup'; +import CountyMapPopup, { CountyData, CountyType } from './CountyMapPopup'; import { Property } from 'csstype'; import { cities } from './data'; @@ -22,7 +22,7 @@ interface IProps { showCountyMapOnClick?: boolean, countyData?: Record, countyMapWrapper?: (countyMapPopup: React.ReactElement, city: CityType, countyData: CountyData) => React.ReactElement, - onCountyClick?: (county: { id: string; name: string; path: string }, city: CityType) => void + onCountyClick?: (county: CountyType, city: CityType) => void } export type CityType = { id: string; plateNumber: number; name: string; path: string }; @@ -177,7 +177,7 @@ const TurkeyMap: React.FC = ({ setSelectedCountyData(null); }, []); - const handleCountyClickInternal = useCallback((county: { id: string; name: string; path: string }) => { + const handleCountyClickInternal = useCallback((county: CountyType) => { if (onCountyClick && selectedCountyData) { onCountyClick(county, selectedCountyData.city); } From d327e55f84e689dccab3e478580c70b071f6567a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:37:04 +0000 Subject: [PATCH 4/4] docs: clarify sample data is for demonstration only Co-authored-by: erdigokce <17235148+erdigokce@users.noreply.github.com> --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index e11c2bf..94ca9e9 100644 --- a/readme.md +++ b/readme.md @@ -164,6 +164,8 @@ const myCustomCountyData: CountyData = { /> ``` +**Note on Sample Data:** The built-in county data for Istanbul, Ankara, and Izmir uses placeholder rectangular paths for demonstration purposes only. These are NOT actual geographic boundaries. For production use, you should provide real geographic SVG data for county boundaries. Consider using geographic data sources like GADM or OpenStreetMap for accurate county shapes. + ## API ### Types